1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 mathrand "math/rand/v2"
12 "net/http"
13 "path"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "github.com/bluesky-social/indigo/atproto/data"
20 "github.com/bluesky-social/indigo/atproto/identity"
21 "github.com/bluesky-social/indigo/atproto/syntax"
22 securejoin "github.com/cyphar/filepath-securejoin"
23 "github.com/go-chi/chi/v5"
24 "github.com/go-git/go-git/v5/plumbing"
25 "tangled.sh/tangled.sh/core/api/tangled"
26 "tangled.sh/tangled.sh/core/appview"
27 "tangled.sh/tangled.sh/core/appview/auth"
28 "tangled.sh/tangled.sh/core/appview/db"
29 "tangled.sh/tangled.sh/core/appview/pages"
30 "tangled.sh/tangled.sh/core/appview/pages/markup"
31 "tangled.sh/tangled.sh/core/appview/pagination"
32 "tangled.sh/tangled.sh/core/types"
33
34 comatproto "github.com/bluesky-social/indigo/api/atproto"
35 lexutil "github.com/bluesky-social/indigo/lex/util"
36)
37
38func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
39 ref := chi.URLParam(r, "ref")
40 f, err := s.fullyResolvedRepo(r)
41 if err != nil {
42 log.Println("failed to fully resolve repo", err)
43 return
44 }
45
46 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
47 if err != nil {
48 log.Printf("failed to create unsigned client for %s", f.Knot)
49 s.pages.Error503(w)
50 return
51 }
52
53 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
54 if err != nil {
55 s.pages.Error503(w)
56 log.Println("failed to reach knotserver", err)
57 return
58 }
59 defer resp.Body.Close()
60
61 body, err := io.ReadAll(resp.Body)
62 if err != nil {
63 log.Printf("Error reading response body: %v", err)
64 return
65 }
66
67 var result types.RepoIndexResponse
68 err = json.Unmarshal(body, &result)
69 if err != nil {
70 log.Printf("Error unmarshalling response body: %v", err)
71 return
72 }
73
74 tagMap := make(map[string][]string)
75 for _, tag := range result.Tags {
76 hash := tag.Hash
77 if tag.Tag != nil {
78 hash = tag.Tag.Target.String()
79 }
80 tagMap[hash] = append(tagMap[hash], tag.Name)
81 }
82
83 for _, branch := range result.Branches {
84 hash := branch.Hash
85 tagMap[hash] = append(tagMap[hash], branch.Name)
86 }
87
88 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
89 if a.Name == result.Ref {
90 return -1
91 }
92 if a.IsDefault {
93 return -1
94 }
95 if a.Commit != nil {
96 if a.Commit.Author.When.Before(b.Commit.Author.When) {
97 return 1
98 } else {
99 return -1
100 }
101 }
102 return strings.Compare(a.Name, b.Name) * -1
103 })
104
105 commitCount := len(result.Commits)
106 branchCount := len(result.Branches)
107 tagCount := len(result.Tags)
108 fileCount := len(result.Files)
109
110 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
111 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
112 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
113 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
114
115 emails := uniqueEmails(commitsTrunc)
116
117 user := s.auth.GetUser(r)
118 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
119 LoggedInUser: user,
120 RepoInfo: f.RepoInfo(s, user),
121 TagMap: tagMap,
122 RepoIndexResponse: result,
123 CommitsTrunc: commitsTrunc,
124 TagsTrunc: tagsTrunc,
125 BranchesTrunc: branchesTrunc,
126 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
127 })
128 return
129}
130
131func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
132 f, err := s.fullyResolvedRepo(r)
133 if err != nil {
134 log.Println("failed to fully resolve repo", err)
135 return
136 }
137
138 page := 1
139 if r.URL.Query().Get("page") != "" {
140 page, err = strconv.Atoi(r.URL.Query().Get("page"))
141 if err != nil {
142 page = 1
143 }
144 }
145
146 ref := chi.URLParam(r, "ref")
147
148 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
149 if err != nil {
150 log.Println("failed to create unsigned client", err)
151 return
152 }
153
154 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
155 if err != nil {
156 log.Println("failed to reach knotserver", err)
157 return
158 }
159
160 body, err := io.ReadAll(resp.Body)
161 if err != nil {
162 log.Printf("error reading response body: %v", err)
163 return
164 }
165
166 var repolog types.RepoLogResponse
167 err = json.Unmarshal(body, &repolog)
168 if err != nil {
169 log.Println("failed to parse json response", err)
170 return
171 }
172
173 resp, err = us.Tags(f.OwnerDid(), f.RepoName)
174 if err != nil {
175 log.Println("failed to reach knotserver", err)
176 return
177 }
178
179 body, err = io.ReadAll(resp.Body)
180 if err != nil {
181 log.Printf("error reading response body: %v", err)
182 return
183 }
184
185 var result types.RepoTagsResponse
186 err = json.Unmarshal(body, &result)
187 if err != nil {
188 log.Printf("Error unmarshalling response body: %v", err)
189 return
190 }
191
192 tagMap := make(map[string][]string)
193 for _, tag := range result.Tags {
194 hash := tag.Hash
195 if tag.Tag != nil {
196 hash = tag.Tag.Target.String()
197 }
198 tagMap[hash] = append(tagMap[hash], tag.Name)
199 }
200
201 user := s.auth.GetUser(r)
202 s.pages.RepoLog(w, pages.RepoLogParams{
203 LoggedInUser: user,
204 TagMap: tagMap,
205 RepoInfo: f.RepoInfo(s, user),
206 RepoLogResponse: repolog,
207 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
208 })
209 return
210}
211
212func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
213 f, err := s.fullyResolvedRepo(r)
214 if err != nil {
215 log.Println("failed to get repo and knot", err)
216 w.WriteHeader(http.StatusBadRequest)
217 return
218 }
219
220 user := s.auth.GetUser(r)
221 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
222 RepoInfo: f.RepoInfo(s, user),
223 })
224 return
225}
226
227func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
228 f, err := s.fullyResolvedRepo(r)
229 if err != nil {
230 log.Println("failed to get repo and knot", err)
231 w.WriteHeader(http.StatusBadRequest)
232 return
233 }
234
235 repoAt := f.RepoAt
236 rkey := repoAt.RecordKey().String()
237 if rkey == "" {
238 log.Println("invalid aturi for repo", err)
239 w.WriteHeader(http.StatusInternalServerError)
240 return
241 }
242
243 user := s.auth.GetUser(r)
244
245 switch r.Method {
246 case http.MethodGet:
247 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
248 RepoInfo: f.RepoInfo(s, user),
249 })
250 return
251 case http.MethodPut:
252 user := s.auth.GetUser(r)
253 newDescription := r.FormValue("description")
254 client, _ := s.auth.AuthorizedClient(r)
255
256 // optimistic update
257 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
258 if err != nil {
259 log.Println("failed to perferom update-description query", err)
260 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
261 return
262 }
263
264 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
265 //
266 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
267 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
268 if err != nil {
269 // failed to get record
270 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
271 return
272 }
273 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
274 Collection: tangled.RepoNSID,
275 Repo: user.Did,
276 Rkey: rkey,
277 SwapRecord: ex.Cid,
278 Record: &lexutil.LexiconTypeDecoder{
279 Val: &tangled.Repo{
280 Knot: f.Knot,
281 Name: f.RepoName,
282 Owner: user.Did,
283 CreatedAt: f.CreatedAt,
284 Description: &newDescription,
285 },
286 },
287 })
288
289 if err != nil {
290 log.Println("failed to perferom update-description query", err)
291 // failed to get record
292 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
293 return
294 }
295
296 newRepoInfo := f.RepoInfo(s, user)
297 newRepoInfo.Description = newDescription
298
299 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
300 RepoInfo: newRepoInfo,
301 })
302 return
303 }
304}
305
306func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
307 f, err := s.fullyResolvedRepo(r)
308 if err != nil {
309 log.Println("failed to fully resolve repo", err)
310 return
311 }
312 ref := chi.URLParam(r, "ref")
313 protocol := "http"
314 if !s.config.Dev {
315 protocol = "https"
316 }
317
318 if !plumbing.IsHash(ref) {
319 s.pages.Error404(w)
320 return
321 }
322
323 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
324 if err != nil {
325 log.Println("failed to reach knotserver", err)
326 return
327 }
328
329 body, err := io.ReadAll(resp.Body)
330 if err != nil {
331 log.Printf("Error reading response body: %v", err)
332 return
333 }
334
335 var result types.RepoCommitResponse
336 err = json.Unmarshal(body, &result)
337 if err != nil {
338 log.Println("failed to parse response:", err)
339 return
340 }
341
342 user := s.auth.GetUser(r)
343 s.pages.RepoCommit(w, pages.RepoCommitParams{
344 LoggedInUser: user,
345 RepoInfo: f.RepoInfo(s, user),
346 RepoCommitResponse: result,
347 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
348 })
349 return
350}
351
352func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
353 f, err := s.fullyResolvedRepo(r)
354 if err != nil {
355 log.Println("failed to fully resolve repo", err)
356 return
357 }
358
359 ref := chi.URLParam(r, "ref")
360 treePath := chi.URLParam(r, "*")
361 protocol := "http"
362 if !s.config.Dev {
363 protocol = "https"
364 }
365 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
366 if err != nil {
367 log.Println("failed to reach knotserver", err)
368 return
369 }
370
371 body, err := io.ReadAll(resp.Body)
372 if err != nil {
373 log.Printf("Error reading response body: %v", err)
374 return
375 }
376
377 var result types.RepoTreeResponse
378 err = json.Unmarshal(body, &result)
379 if err != nil {
380 log.Println("failed to parse response:", err)
381 return
382 }
383
384 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
385 // so we can safely redirect to the "parent" (which is the same file).
386 if len(result.Files) == 0 && result.Parent == treePath {
387 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
388 return
389 }
390
391 user := s.auth.GetUser(r)
392
393 var breadcrumbs [][]string
394 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
395 if treePath != "" {
396 for idx, elem := range strings.Split(treePath, "/") {
397 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
398 }
399 }
400
401 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
402 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
403
404 s.pages.RepoTree(w, pages.RepoTreeParams{
405 LoggedInUser: user,
406 BreadCrumbs: breadcrumbs,
407 BaseTreeLink: baseTreeLink,
408 BaseBlobLink: baseBlobLink,
409 RepoInfo: f.RepoInfo(s, user),
410 RepoTreeResponse: result,
411 })
412 return
413}
414
415func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
416 f, err := s.fullyResolvedRepo(r)
417 if err != nil {
418 log.Println("failed to get repo and knot", err)
419 return
420 }
421
422 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
423 if err != nil {
424 log.Println("failed to create unsigned client", err)
425 return
426 }
427
428 resp, err := us.Tags(f.OwnerDid(), f.RepoName)
429 if err != nil {
430 log.Println("failed to reach knotserver", err)
431 return
432 }
433
434 body, err := io.ReadAll(resp.Body)
435 if err != nil {
436 log.Printf("Error reading response body: %v", err)
437 return
438 }
439
440 var result types.RepoTagsResponse
441 err = json.Unmarshal(body, &result)
442 if err != nil {
443 log.Println("failed to parse response:", err)
444 return
445 }
446
447 user := s.auth.GetUser(r)
448 s.pages.RepoTags(w, pages.RepoTagsParams{
449 LoggedInUser: user,
450 RepoInfo: f.RepoInfo(s, user),
451 RepoTagsResponse: result,
452 })
453 return
454}
455
456func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
457 f, err := s.fullyResolvedRepo(r)
458 if err != nil {
459 log.Println("failed to get repo and knot", err)
460 return
461 }
462
463 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
464 if err != nil {
465 log.Println("failed to create unsigned client", err)
466 return
467 }
468
469 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
470 if err != nil {
471 log.Println("failed to reach knotserver", err)
472 return
473 }
474
475 body, err := io.ReadAll(resp.Body)
476 if err != nil {
477 log.Printf("Error reading response body: %v", err)
478 return
479 }
480
481 var result types.RepoBranchesResponse
482 err = json.Unmarshal(body, &result)
483 if err != nil {
484 log.Println("failed to parse response:", err)
485 return
486 }
487
488 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
489 if a.IsDefault {
490 return -1
491 }
492 if a.Commit != nil {
493 if a.Commit.Author.When.Before(b.Commit.Author.When) {
494 return 1
495 } else {
496 return -1
497 }
498 }
499 return strings.Compare(a.Name, b.Name) * -1
500 })
501
502 user := s.auth.GetUser(r)
503 s.pages.RepoBranches(w, pages.RepoBranchesParams{
504 LoggedInUser: user,
505 RepoInfo: f.RepoInfo(s, user),
506 RepoBranchesResponse: result,
507 })
508 return
509}
510
511func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
512 f, err := s.fullyResolvedRepo(r)
513 if err != nil {
514 log.Println("failed to get repo and knot", err)
515 return
516 }
517
518 ref := chi.URLParam(r, "ref")
519 filePath := chi.URLParam(r, "*")
520 protocol := "http"
521 if !s.config.Dev {
522 protocol = "https"
523 }
524 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
525 if err != nil {
526 log.Println("failed to reach knotserver", err)
527 return
528 }
529
530 body, err := io.ReadAll(resp.Body)
531 if err != nil {
532 log.Printf("Error reading response body: %v", err)
533 return
534 }
535
536 var result types.RepoBlobResponse
537 err = json.Unmarshal(body, &result)
538 if err != nil {
539 log.Println("failed to parse response:", err)
540 return
541 }
542
543 var breadcrumbs [][]string
544 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
545 if filePath != "" {
546 for idx, elem := range strings.Split(filePath, "/") {
547 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
548 }
549 }
550
551 showRendered := false
552 renderToggle := false
553
554 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
555 renderToggle = true
556 showRendered = r.URL.Query().Get("code") != "true"
557 }
558
559 user := s.auth.GetUser(r)
560 s.pages.RepoBlob(w, pages.RepoBlobParams{
561 LoggedInUser: user,
562 RepoInfo: f.RepoInfo(s, user),
563 RepoBlobResponse: result,
564 BreadCrumbs: breadcrumbs,
565 ShowRendered: showRendered,
566 RenderToggle: renderToggle,
567 })
568 return
569}
570
571func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
572 f, err := s.fullyResolvedRepo(r)
573 if err != nil {
574 log.Println("failed to get repo and knot", err)
575 return
576 }
577
578 ref := chi.URLParam(r, "ref")
579 filePath := chi.URLParam(r, "*")
580
581 protocol := "http"
582 if !s.config.Dev {
583 protocol = "https"
584 }
585 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
586 if err != nil {
587 log.Println("failed to reach knotserver", err)
588 return
589 }
590
591 body, err := io.ReadAll(resp.Body)
592 if err != nil {
593 log.Printf("Error reading response body: %v", err)
594 return
595 }
596
597 var result types.RepoBlobResponse
598 err = json.Unmarshal(body, &result)
599 if err != nil {
600 log.Println("failed to parse response:", err)
601 return
602 }
603
604 if result.IsBinary {
605 w.Header().Set("Content-Type", "application/octet-stream")
606 w.Write(body)
607 return
608 }
609
610 w.Header().Set("Content-Type", "text/plain")
611 w.Write([]byte(result.Contents))
612 return
613}
614
615func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
616 f, err := s.fullyResolvedRepo(r)
617 if err != nil {
618 log.Println("failed to get repo and knot", err)
619 return
620 }
621
622 collaborator := r.FormValue("collaborator")
623 if collaborator == "" {
624 http.Error(w, "malformed form", http.StatusBadRequest)
625 return
626 }
627
628 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
629 if err != nil {
630 w.Write([]byte("failed to resolve collaborator did to a handle"))
631 return
632 }
633 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
634
635 // TODO: create an atproto record for this
636
637 secret, err := db.GetRegistrationKey(s.db, f.Knot)
638 if err != nil {
639 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
640 return
641 }
642
643 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
644 if err != nil {
645 log.Println("failed to create client to ", f.Knot)
646 return
647 }
648
649 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
650 if err != nil {
651 log.Printf("failed to make request to %s: %s", f.Knot, err)
652 return
653 }
654
655 if ksResp.StatusCode != http.StatusNoContent {
656 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
657 return
658 }
659
660 tx, err := s.db.BeginTx(r.Context(), nil)
661 if err != nil {
662 log.Println("failed to start tx")
663 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
664 return
665 }
666 defer func() {
667 tx.Rollback()
668 err = s.enforcer.E.LoadPolicy()
669 if err != nil {
670 log.Println("failed to rollback policies")
671 }
672 }()
673
674 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
675 if err != nil {
676 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
677 return
678 }
679
680 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
681 if err != nil {
682 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
683 return
684 }
685
686 err = tx.Commit()
687 if err != nil {
688 log.Println("failed to commit changes", err)
689 http.Error(w, err.Error(), http.StatusInternalServerError)
690 return
691 }
692
693 err = s.enforcer.E.SavePolicy()
694 if err != nil {
695 log.Println("failed to update ACLs", err)
696 http.Error(w, err.Error(), http.StatusInternalServerError)
697 return
698 }
699
700 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
701
702}
703
704func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
705 user := s.auth.GetUser(r)
706
707 f, err := s.fullyResolvedRepo(r)
708 if err != nil {
709 log.Println("failed to get repo and knot", err)
710 return
711 }
712
713 // remove record from pds
714 xrpcClient, _ := s.auth.AuthorizedClient(r)
715 repoRkey := f.RepoAt.RecordKey().String()
716 _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{
717 Collection: tangled.RepoNSID,
718 Repo: user.Did,
719 Rkey: repoRkey,
720 })
721 if err != nil {
722 log.Printf("failed to delete record: %s", err)
723 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
724 return
725 }
726 log.Println("removed repo record ", f.RepoAt.String())
727
728 secret, err := db.GetRegistrationKey(s.db, f.Knot)
729 if err != nil {
730 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
731 return
732 }
733
734 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
735 if err != nil {
736 log.Println("failed to create client to ", f.Knot)
737 return
738 }
739
740 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
741 if err != nil {
742 log.Printf("failed to make request to %s: %s", f.Knot, err)
743 return
744 }
745
746 if ksResp.StatusCode != http.StatusNoContent {
747 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
748 } else {
749 log.Println("removed repo from knot ", f.Knot)
750 }
751
752 tx, err := s.db.BeginTx(r.Context(), nil)
753 if err != nil {
754 log.Println("failed to start tx")
755 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
756 return
757 }
758 defer func() {
759 tx.Rollback()
760 err = s.enforcer.E.LoadPolicy()
761 if err != nil {
762 log.Println("failed to rollback policies")
763 }
764 }()
765
766 // remove collaborator RBAC
767 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
768 if err != nil {
769 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
770 return
771 }
772 for _, c := range repoCollaborators {
773 did := c[0]
774 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
775 }
776 log.Println("removed collaborators")
777
778 // remove repo RBAC
779 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
780 if err != nil {
781 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
782 return
783 }
784
785 // remove repo from db
786 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
787 if err != nil {
788 s.pages.Notice(w, "settings-delete", "Failed to update appview")
789 return
790 }
791 log.Println("removed repo from db")
792
793 err = tx.Commit()
794 if err != nil {
795 log.Println("failed to commit changes", err)
796 http.Error(w, err.Error(), http.StatusInternalServerError)
797 return
798 }
799
800 err = s.enforcer.E.SavePolicy()
801 if err != nil {
802 log.Println("failed to update ACLs", err)
803 http.Error(w, err.Error(), http.StatusInternalServerError)
804 return
805 }
806
807 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
808}
809
810func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
811 f, err := s.fullyResolvedRepo(r)
812 if err != nil {
813 log.Println("failed to get repo and knot", err)
814 return
815 }
816
817 branch := r.FormValue("branch")
818 if branch == "" {
819 http.Error(w, "malformed form", http.StatusBadRequest)
820 return
821 }
822
823 secret, err := db.GetRegistrationKey(s.db, f.Knot)
824 if err != nil {
825 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
826 return
827 }
828
829 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
830 if err != nil {
831 log.Println("failed to create client to ", f.Knot)
832 return
833 }
834
835 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
836 if err != nil {
837 log.Printf("failed to make request to %s: %s", f.Knot, err)
838 return
839 }
840
841 if ksResp.StatusCode != http.StatusNoContent {
842 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
843 return
844 }
845
846 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
847}
848
849func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
850 f, err := s.fullyResolvedRepo(r)
851 if err != nil {
852 log.Println("failed to get repo and knot", err)
853 return
854 }
855
856 switch r.Method {
857 case http.MethodGet:
858 // for now, this is just pubkeys
859 user := s.auth.GetUser(r)
860 repoCollaborators, err := f.Collaborators(r.Context(), s)
861 if err != nil {
862 log.Println("failed to get collaborators", err)
863 }
864
865 isCollaboratorInviteAllowed := false
866 if user != nil {
867 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
868 if err == nil && ok {
869 isCollaboratorInviteAllowed = true
870 }
871 }
872
873 var branchNames []string
874 var defaultBranch string
875 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
876 if err != nil {
877 log.Println("failed to create unsigned client", err)
878 } else {
879 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
880 if err != nil {
881 log.Println("failed to reach knotserver", err)
882 } else {
883 defer resp.Body.Close()
884
885 body, err := io.ReadAll(resp.Body)
886 if err != nil {
887 log.Printf("Error reading response body: %v", err)
888 } else {
889 var result types.RepoBranchesResponse
890 err = json.Unmarshal(body, &result)
891 if err != nil {
892 log.Println("failed to parse response:", err)
893 } else {
894 for _, branch := range result.Branches {
895 branchNames = append(branchNames, branch.Name)
896 }
897 }
898 }
899 }
900
901 defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName)
902 if err != nil {
903 log.Println("failed to reach knotserver", err)
904 } else {
905 defaultBranch = defaultBranchResp.Branch
906 }
907 }
908 s.pages.RepoSettings(w, pages.RepoSettingsParams{
909 LoggedInUser: user,
910 RepoInfo: f.RepoInfo(s, user),
911 Collaborators: repoCollaborators,
912 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
913 Branches: branchNames,
914 DefaultBranch: defaultBranch,
915 })
916 }
917}
918
919type FullyResolvedRepo struct {
920 Knot string
921 OwnerId identity.Identity
922 RepoName string
923 RepoAt syntax.ATURI
924 Description string
925 CreatedAt string
926 Ref string
927}
928
929func (f *FullyResolvedRepo) OwnerDid() string {
930 return f.OwnerId.DID.String()
931}
932
933func (f *FullyResolvedRepo) OwnerHandle() string {
934 return f.OwnerId.Handle.String()
935}
936
937func (f *FullyResolvedRepo) OwnerSlashRepo() string {
938 handle := f.OwnerId.Handle
939
940 var p string
941 if handle != "" && !handle.IsInvalidHandle() {
942 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
943 } else {
944 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
945 }
946
947 return p
948}
949
950func (f *FullyResolvedRepo) DidSlashRepo() string {
951 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
952 return p
953}
954
955func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
956 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
957 if err != nil {
958 return nil, err
959 }
960
961 var collaborators []pages.Collaborator
962 for _, item := range repoCollaborators {
963 // currently only two roles: owner and member
964 var role string
965 if item[3] == "repo:owner" {
966 role = "owner"
967 } else if item[3] == "repo:collaborator" {
968 role = "collaborator"
969 } else {
970 continue
971 }
972
973 did := item[0]
974
975 c := pages.Collaborator{
976 Did: did,
977 Handle: "",
978 Role: role,
979 }
980 collaborators = append(collaborators, c)
981 }
982
983 // populate all collborators with handles
984 identsToResolve := make([]string, len(collaborators))
985 for i, collab := range collaborators {
986 identsToResolve[i] = collab.Did
987 }
988
989 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
990 for i, resolved := range resolvedIdents {
991 if resolved != nil {
992 collaborators[i].Handle = resolved.Handle.String()
993 }
994 }
995
996 return collaborators, nil
997}
998
999func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
1000 isStarred := false
1001 if u != nil {
1002 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
1003 }
1004
1005 starCount, err := db.GetStarCount(s.db, f.RepoAt)
1006 if err != nil {
1007 log.Println("failed to get star count for ", f.RepoAt)
1008 }
1009 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
1010 if err != nil {
1011 log.Println("failed to get issue count for ", f.RepoAt)
1012 }
1013 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
1014 if err != nil {
1015 log.Println("failed to get issue count for ", f.RepoAt)
1016 }
1017 source, err := db.GetRepoSource(s.db, f.RepoAt)
1018 if errors.Is(err, sql.ErrNoRows) {
1019 source = ""
1020 } else if err != nil {
1021 log.Println("failed to get repo source for ", f.RepoAt, err)
1022 }
1023
1024 var sourceRepo *db.Repo
1025 if source != "" {
1026 sourceRepo, err = db.GetRepoByAtUri(s.db, source)
1027 if err != nil {
1028 log.Println("failed to get repo by at uri", err)
1029 }
1030 }
1031
1032 var sourceHandle *identity.Identity
1033 if sourceRepo != nil {
1034 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
1035 if err != nil {
1036 log.Println("failed to resolve source repo", err)
1037 }
1038 }
1039
1040 knot := f.Knot
1041 var disableFork bool
1042 us, err := NewUnsignedClient(knot, s.config.Dev)
1043 if err != nil {
1044 log.Printf("failed to create unsigned client for %s: %v", knot, err)
1045 } else {
1046 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1047 if err != nil {
1048 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1049 } else {
1050 defer resp.Body.Close()
1051 body, err := io.ReadAll(resp.Body)
1052 if err != nil {
1053 log.Printf("error reading branch response body: %v", err)
1054 } else {
1055 var branchesResp types.RepoBranchesResponse
1056 if err := json.Unmarshal(body, &branchesResp); err != nil {
1057 log.Printf("error parsing branch response: %v", err)
1058 } else {
1059 disableFork = false
1060 }
1061
1062 if len(branchesResp.Branches) == 0 {
1063 disableFork = true
1064 }
1065 }
1066 }
1067 }
1068
1069 if knot == "knot1.tangled.sh" {
1070 knot = "tangled.sh"
1071 }
1072
1073 repoInfo := pages.RepoInfo{
1074 OwnerDid: f.OwnerDid(),
1075 OwnerHandle: f.OwnerHandle(),
1076 Name: f.RepoName,
1077 RepoAt: f.RepoAt,
1078 Description: f.Description,
1079 Ref: f.Ref,
1080 IsStarred: isStarred,
1081 Knot: knot,
1082 Roles: RolesInRepo(s, u, f),
1083 Stats: db.RepoStats{
1084 StarCount: starCount,
1085 IssueCount: issueCount,
1086 PullCount: pullCount,
1087 },
1088 DisableFork: disableFork,
1089 }
1090
1091 if sourceRepo != nil {
1092 repoInfo.Source = sourceRepo
1093 repoInfo.SourceHandle = sourceHandle.Handle.String()
1094 }
1095
1096 return repoInfo
1097}
1098
1099func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1100 user := s.auth.GetUser(r)
1101 f, err := s.fullyResolvedRepo(r)
1102 if err != nil {
1103 log.Println("failed to get repo and knot", err)
1104 return
1105 }
1106
1107 issueId := chi.URLParam(r, "issue")
1108 issueIdInt, err := strconv.Atoi(issueId)
1109 if err != nil {
1110 http.Error(w, "bad issue id", http.StatusBadRequest)
1111 log.Println("failed to parse issue id", err)
1112 return
1113 }
1114
1115 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1116 if err != nil {
1117 log.Println("failed to get issue and comments", err)
1118 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1119 return
1120 }
1121
1122 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1123 if err != nil {
1124 log.Println("failed to resolve issue owner", err)
1125 }
1126
1127 identsToResolve := make([]string, len(comments))
1128 for i, comment := range comments {
1129 identsToResolve[i] = comment.OwnerDid
1130 }
1131 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1132 didHandleMap := make(map[string]string)
1133 for _, identity := range resolvedIds {
1134 if !identity.Handle.IsInvalidHandle() {
1135 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1136 } else {
1137 didHandleMap[identity.DID.String()] = identity.DID.String()
1138 }
1139 }
1140
1141 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1142 LoggedInUser: user,
1143 RepoInfo: f.RepoInfo(s, user),
1144 Issue: *issue,
1145 Comments: comments,
1146
1147 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1148 DidHandleMap: didHandleMap,
1149 })
1150
1151}
1152
1153func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1154 user := s.auth.GetUser(r)
1155 f, err := s.fullyResolvedRepo(r)
1156 if err != nil {
1157 log.Println("failed to get repo and knot", err)
1158 return
1159 }
1160
1161 issueId := chi.URLParam(r, "issue")
1162 issueIdInt, err := strconv.Atoi(issueId)
1163 if err != nil {
1164 http.Error(w, "bad issue id", http.StatusBadRequest)
1165 log.Println("failed to parse issue id", err)
1166 return
1167 }
1168
1169 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1170 if err != nil {
1171 log.Println("failed to get issue", err)
1172 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1173 return
1174 }
1175
1176 collaborators, err := f.Collaborators(r.Context(), s)
1177 if err != nil {
1178 log.Println("failed to fetch repo collaborators: %w", err)
1179 }
1180 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1181 return user.Did == collab.Did
1182 })
1183 isIssueOwner := user.Did == issue.OwnerDid
1184
1185 // TODO: make this more granular
1186 if isIssueOwner || isCollaborator {
1187
1188 closed := tangled.RepoIssueStateClosed
1189
1190 client, _ := s.auth.AuthorizedClient(r)
1191 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1192 Collection: tangled.RepoIssueStateNSID,
1193 Repo: user.Did,
1194 Rkey: appview.TID(),
1195 Record: &lexutil.LexiconTypeDecoder{
1196 Val: &tangled.RepoIssueState{
1197 Issue: issue.IssueAt,
1198 State: closed,
1199 },
1200 },
1201 })
1202
1203 if err != nil {
1204 log.Println("failed to update issue state", err)
1205 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1206 return
1207 }
1208
1209 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1210 if err != nil {
1211 log.Println("failed to close issue", err)
1212 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1213 return
1214 }
1215
1216 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1217 return
1218 } else {
1219 log.Println("user is not permitted to close issue")
1220 http.Error(w, "for biden", http.StatusUnauthorized)
1221 return
1222 }
1223}
1224
1225func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1226 user := s.auth.GetUser(r)
1227 f, err := s.fullyResolvedRepo(r)
1228 if err != nil {
1229 log.Println("failed to get repo and knot", err)
1230 return
1231 }
1232
1233 issueId := chi.URLParam(r, "issue")
1234 issueIdInt, err := strconv.Atoi(issueId)
1235 if err != nil {
1236 http.Error(w, "bad issue id", http.StatusBadRequest)
1237 log.Println("failed to parse issue id", err)
1238 return
1239 }
1240
1241 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1242 if err != nil {
1243 log.Println("failed to get issue", err)
1244 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1245 return
1246 }
1247
1248 collaborators, err := f.Collaborators(r.Context(), s)
1249 if err != nil {
1250 log.Println("failed to fetch repo collaborators: %w", err)
1251 }
1252 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1253 return user.Did == collab.Did
1254 })
1255 isIssueOwner := user.Did == issue.OwnerDid
1256
1257 if isCollaborator || isIssueOwner {
1258 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1259 if err != nil {
1260 log.Println("failed to reopen issue", err)
1261 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1262 return
1263 }
1264 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1265 return
1266 } else {
1267 log.Println("user is not the owner of the repo")
1268 http.Error(w, "forbidden", http.StatusUnauthorized)
1269 return
1270 }
1271}
1272
1273func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1274 user := s.auth.GetUser(r)
1275 f, err := s.fullyResolvedRepo(r)
1276 if err != nil {
1277 log.Println("failed to get repo and knot", err)
1278 return
1279 }
1280
1281 issueId := chi.URLParam(r, "issue")
1282 issueIdInt, err := strconv.Atoi(issueId)
1283 if err != nil {
1284 http.Error(w, "bad issue id", http.StatusBadRequest)
1285 log.Println("failed to parse issue id", err)
1286 return
1287 }
1288
1289 switch r.Method {
1290 case http.MethodPost:
1291 body := r.FormValue("body")
1292 if body == "" {
1293 s.pages.Notice(w, "issue", "Body is required")
1294 return
1295 }
1296
1297 commentId := mathrand.IntN(1000000)
1298 rkey := appview.TID()
1299
1300 err := db.NewIssueComment(s.db, &db.Comment{
1301 OwnerDid: user.Did,
1302 RepoAt: f.RepoAt,
1303 Issue: issueIdInt,
1304 CommentId: commentId,
1305 Body: body,
1306 Rkey: rkey,
1307 })
1308 if err != nil {
1309 log.Println("failed to create comment", err)
1310 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1311 return
1312 }
1313
1314 createdAt := time.Now().Format(time.RFC3339)
1315 commentIdInt64 := int64(commentId)
1316 ownerDid := user.Did
1317 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1318 if err != nil {
1319 log.Println("failed to get issue at", err)
1320 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1321 return
1322 }
1323
1324 atUri := f.RepoAt.String()
1325 client, _ := s.auth.AuthorizedClient(r)
1326 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1327 Collection: tangled.RepoIssueCommentNSID,
1328 Repo: user.Did,
1329 Rkey: rkey,
1330 Record: &lexutil.LexiconTypeDecoder{
1331 Val: &tangled.RepoIssueComment{
1332 Repo: &atUri,
1333 Issue: issueAt,
1334 CommentId: &commentIdInt64,
1335 Owner: &ownerDid,
1336 Body: body,
1337 CreatedAt: createdAt,
1338 },
1339 },
1340 })
1341 if err != nil {
1342 log.Println("failed to create comment", err)
1343 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1344 return
1345 }
1346
1347 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1348 return
1349 }
1350}
1351
1352func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1353 user := s.auth.GetUser(r)
1354 f, err := s.fullyResolvedRepo(r)
1355 if err != nil {
1356 log.Println("failed to get repo and knot", err)
1357 return
1358 }
1359
1360 issueId := chi.URLParam(r, "issue")
1361 issueIdInt, err := strconv.Atoi(issueId)
1362 if err != nil {
1363 http.Error(w, "bad issue id", http.StatusBadRequest)
1364 log.Println("failed to parse issue id", err)
1365 return
1366 }
1367
1368 commentId := chi.URLParam(r, "comment_id")
1369 commentIdInt, err := strconv.Atoi(commentId)
1370 if err != nil {
1371 http.Error(w, "bad comment id", http.StatusBadRequest)
1372 log.Println("failed to parse issue id", err)
1373 return
1374 }
1375
1376 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1377 if err != nil {
1378 log.Println("failed to get issue", err)
1379 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1380 return
1381 }
1382
1383 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1384 if err != nil {
1385 http.Error(w, "bad comment id", http.StatusBadRequest)
1386 return
1387 }
1388
1389 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1390 if err != nil {
1391 log.Println("failed to resolve did")
1392 return
1393 }
1394
1395 didHandleMap := make(map[string]string)
1396 if !identity.Handle.IsInvalidHandle() {
1397 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1398 } else {
1399 didHandleMap[identity.DID.String()] = identity.DID.String()
1400 }
1401
1402 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1403 LoggedInUser: user,
1404 RepoInfo: f.RepoInfo(s, user),
1405 DidHandleMap: didHandleMap,
1406 Issue: issue,
1407 Comment: comment,
1408 })
1409}
1410
1411func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1412 user := s.auth.GetUser(r)
1413 f, err := s.fullyResolvedRepo(r)
1414 if err != nil {
1415 log.Println("failed to get repo and knot", err)
1416 return
1417 }
1418
1419 issueId := chi.URLParam(r, "issue")
1420 issueIdInt, err := strconv.Atoi(issueId)
1421 if err != nil {
1422 http.Error(w, "bad issue id", http.StatusBadRequest)
1423 log.Println("failed to parse issue id", err)
1424 return
1425 }
1426
1427 commentId := chi.URLParam(r, "comment_id")
1428 commentIdInt, err := strconv.Atoi(commentId)
1429 if err != nil {
1430 http.Error(w, "bad comment id", http.StatusBadRequest)
1431 log.Println("failed to parse issue id", err)
1432 return
1433 }
1434
1435 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1436 if err != nil {
1437 log.Println("failed to get issue", err)
1438 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1439 return
1440 }
1441
1442 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1443 if err != nil {
1444 http.Error(w, "bad comment id", http.StatusBadRequest)
1445 return
1446 }
1447
1448 if comment.OwnerDid != user.Did {
1449 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1450 return
1451 }
1452
1453 switch r.Method {
1454 case http.MethodGet:
1455 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1456 LoggedInUser: user,
1457 RepoInfo: f.RepoInfo(s, user),
1458 Issue: issue,
1459 Comment: comment,
1460 })
1461 case http.MethodPost:
1462 // extract form value
1463 newBody := r.FormValue("body")
1464 client, _ := s.auth.AuthorizedClient(r)
1465 rkey := comment.Rkey
1466
1467 // optimistic update
1468 edited := time.Now()
1469 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1470 if err != nil {
1471 log.Println("failed to perferom update-description query", err)
1472 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1473 return
1474 }
1475
1476 // rkey is optional, it was introduced later
1477 if comment.Rkey != "" {
1478 // update the record on pds
1479 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1480 if err != nil {
1481 // failed to get record
1482 log.Println(err, rkey)
1483 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1484 return
1485 }
1486 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1487 record, _ := data.UnmarshalJSON(value)
1488
1489 repoAt := record["repo"].(string)
1490 issueAt := record["issue"].(string)
1491 createdAt := record["createdAt"].(string)
1492 commentIdInt64 := int64(commentIdInt)
1493
1494 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1495 Collection: tangled.RepoIssueCommentNSID,
1496 Repo: user.Did,
1497 Rkey: rkey,
1498 SwapRecord: ex.Cid,
1499 Record: &lexutil.LexiconTypeDecoder{
1500 Val: &tangled.RepoIssueComment{
1501 Repo: &repoAt,
1502 Issue: issueAt,
1503 CommentId: &commentIdInt64,
1504 Owner: &comment.OwnerDid,
1505 Body: newBody,
1506 CreatedAt: createdAt,
1507 },
1508 },
1509 })
1510 if err != nil {
1511 log.Println(err)
1512 }
1513 }
1514
1515 // optimistic update for htmx
1516 didHandleMap := map[string]string{
1517 user.Did: user.Handle,
1518 }
1519 comment.Body = newBody
1520 comment.Edited = &edited
1521
1522 // return new comment body with htmx
1523 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1524 LoggedInUser: user,
1525 RepoInfo: f.RepoInfo(s, user),
1526 DidHandleMap: didHandleMap,
1527 Issue: issue,
1528 Comment: comment,
1529 })
1530 return
1531
1532 }
1533
1534}
1535
1536func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1537 user := s.auth.GetUser(r)
1538 f, err := s.fullyResolvedRepo(r)
1539 if err != nil {
1540 log.Println("failed to get repo and knot", err)
1541 return
1542 }
1543
1544 issueId := chi.URLParam(r, "issue")
1545 issueIdInt, err := strconv.Atoi(issueId)
1546 if err != nil {
1547 http.Error(w, "bad issue id", http.StatusBadRequest)
1548 log.Println("failed to parse issue id", err)
1549 return
1550 }
1551
1552 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1553 if err != nil {
1554 log.Println("failed to get issue", err)
1555 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1556 return
1557 }
1558
1559 commentId := chi.URLParam(r, "comment_id")
1560 commentIdInt, err := strconv.Atoi(commentId)
1561 if err != nil {
1562 http.Error(w, "bad comment id", http.StatusBadRequest)
1563 log.Println("failed to parse issue id", err)
1564 return
1565 }
1566
1567 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1568 if err != nil {
1569 http.Error(w, "bad comment id", http.StatusBadRequest)
1570 return
1571 }
1572
1573 if comment.OwnerDid != user.Did {
1574 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1575 return
1576 }
1577
1578 if comment.Deleted != nil {
1579 http.Error(w, "comment already deleted", http.StatusBadRequest)
1580 return
1581 }
1582
1583 // optimistic deletion
1584 deleted := time.Now()
1585 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1586 if err != nil {
1587 log.Println("failed to delete comment")
1588 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1589 return
1590 }
1591
1592 // delete from pds
1593 if comment.Rkey != "" {
1594 client, _ := s.auth.AuthorizedClient(r)
1595 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1596 Collection: tangled.GraphFollowNSID,
1597 Repo: user.Did,
1598 Rkey: comment.Rkey,
1599 })
1600 if err != nil {
1601 log.Println(err)
1602 }
1603 }
1604
1605 // optimistic update for htmx
1606 didHandleMap := map[string]string{
1607 user.Did: user.Handle,
1608 }
1609 comment.Body = ""
1610 comment.Deleted = &deleted
1611
1612 // htmx fragment of comment after deletion
1613 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1614 LoggedInUser: user,
1615 RepoInfo: f.RepoInfo(s, user),
1616 DidHandleMap: didHandleMap,
1617 Issue: issue,
1618 Comment: comment,
1619 })
1620 return
1621}
1622
1623func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1624 params := r.URL.Query()
1625 state := params.Get("state")
1626 isOpen := true
1627 switch state {
1628 case "open":
1629 isOpen = true
1630 case "closed":
1631 isOpen = false
1632 default:
1633 isOpen = true
1634 }
1635
1636 page, ok := r.Context().Value("page").(pagination.Page)
1637 if !ok {
1638 log.Println("failed to get page")
1639 page = pagination.FirstPage()
1640 }
1641
1642 user := s.auth.GetUser(r)
1643 f, err := s.fullyResolvedRepo(r)
1644 if err != nil {
1645 log.Println("failed to get repo and knot", err)
1646 return
1647 }
1648
1649 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1650 if err != nil {
1651 log.Println("failed to get issues", err)
1652 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1653 return
1654 }
1655
1656 identsToResolve := make([]string, len(issues))
1657 for i, issue := range issues {
1658 identsToResolve[i] = issue.OwnerDid
1659 }
1660 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1661 didHandleMap := make(map[string]string)
1662 for _, identity := range resolvedIds {
1663 if !identity.Handle.IsInvalidHandle() {
1664 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1665 } else {
1666 didHandleMap[identity.DID.String()] = identity.DID.String()
1667 }
1668 }
1669
1670 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1671 LoggedInUser: s.auth.GetUser(r),
1672 RepoInfo: f.RepoInfo(s, user),
1673 Issues: issues,
1674 DidHandleMap: didHandleMap,
1675 FilteringByOpen: isOpen,
1676 Page: page,
1677 })
1678 return
1679}
1680
1681func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1682 user := s.auth.GetUser(r)
1683
1684 f, err := s.fullyResolvedRepo(r)
1685 if err != nil {
1686 log.Println("failed to get repo and knot", err)
1687 return
1688 }
1689
1690 switch r.Method {
1691 case http.MethodGet:
1692 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1693 LoggedInUser: user,
1694 RepoInfo: f.RepoInfo(s, user),
1695 })
1696 case http.MethodPost:
1697 title := r.FormValue("title")
1698 body := r.FormValue("body")
1699
1700 if title == "" || body == "" {
1701 s.pages.Notice(w, "issues", "Title and body are required")
1702 return
1703 }
1704
1705 tx, err := s.db.BeginTx(r.Context(), nil)
1706 if err != nil {
1707 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1708 return
1709 }
1710
1711 err = db.NewIssue(tx, &db.Issue{
1712 RepoAt: f.RepoAt,
1713 Title: title,
1714 Body: body,
1715 OwnerDid: user.Did,
1716 })
1717 if err != nil {
1718 log.Println("failed to create issue", err)
1719 s.pages.Notice(w, "issues", "Failed to create issue.")
1720 return
1721 }
1722
1723 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1724 if err != nil {
1725 log.Println("failed to get issue id", err)
1726 s.pages.Notice(w, "issues", "Failed to create issue.")
1727 return
1728 }
1729
1730 client, _ := s.auth.AuthorizedClient(r)
1731 atUri := f.RepoAt.String()
1732 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1733 Collection: tangled.RepoIssueNSID,
1734 Repo: user.Did,
1735 Rkey: appview.TID(),
1736 Record: &lexutil.LexiconTypeDecoder{
1737 Val: &tangled.RepoIssue{
1738 Repo: atUri,
1739 Title: title,
1740 Body: &body,
1741 Owner: user.Did,
1742 IssueId: int64(issueId),
1743 },
1744 },
1745 })
1746 if err != nil {
1747 log.Println("failed to create issue", err)
1748 s.pages.Notice(w, "issues", "Failed to create issue.")
1749 return
1750 }
1751
1752 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1753 if err != nil {
1754 log.Println("failed to set issue at", err)
1755 s.pages.Notice(w, "issues", "Failed to create issue.")
1756 return
1757 }
1758
1759 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1760 return
1761 }
1762}
1763
1764func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1765 user := s.auth.GetUser(r)
1766 f, err := s.fullyResolvedRepo(r)
1767 if err != nil {
1768 log.Printf("failed to resolve source repo: %v", err)
1769 return
1770 }
1771
1772 switch r.Method {
1773 case http.MethodGet:
1774 user := s.auth.GetUser(r)
1775 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1776 if err != nil {
1777 s.pages.Notice(w, "repo", "Invalid user account.")
1778 return
1779 }
1780
1781 s.pages.ForkRepo(w, pages.ForkRepoParams{
1782 LoggedInUser: user,
1783 Knots: knots,
1784 RepoInfo: f.RepoInfo(s, user),
1785 })
1786
1787 case http.MethodPost:
1788
1789 knot := r.FormValue("knot")
1790 if knot == "" {
1791 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1792 return
1793 }
1794
1795 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1796 if err != nil || !ok {
1797 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1798 return
1799 }
1800
1801 forkName := fmt.Sprintf("%s", f.RepoName)
1802
1803 // this check is *only* to see if the forked repo name already exists
1804 // in the user's account.
1805 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1806 if err != nil {
1807 if errors.Is(err, sql.ErrNoRows) {
1808 // no existing repo with this name found, we can use the name as is
1809 } else {
1810 log.Println("error fetching existing repo from db", err)
1811 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1812 return
1813 }
1814 } else if existingRepo != nil {
1815 // repo with this name already exists, append random string
1816 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1817 }
1818 secret, err := db.GetRegistrationKey(s.db, knot)
1819 if err != nil {
1820 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1821 return
1822 }
1823
1824 client, err := NewSignedClient(knot, secret, s.config.Dev)
1825 if err != nil {
1826 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1827 return
1828 }
1829
1830 var uri string
1831 if s.config.Dev {
1832 uri = "http"
1833 } else {
1834 uri = "https"
1835 }
1836 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1837 sourceAt := f.RepoAt.String()
1838
1839 rkey := appview.TID()
1840 repo := &db.Repo{
1841 Did: user.Did,
1842 Name: forkName,
1843 Knot: knot,
1844 Rkey: rkey,
1845 Source: sourceAt,
1846 }
1847
1848 tx, err := s.db.BeginTx(r.Context(), nil)
1849 if err != nil {
1850 log.Println(err)
1851 s.pages.Notice(w, "repo", "Failed to save repository information.")
1852 return
1853 }
1854 defer func() {
1855 tx.Rollback()
1856 err = s.enforcer.E.LoadPolicy()
1857 if err != nil {
1858 log.Println("failed to rollback policies")
1859 }
1860 }()
1861
1862 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1863 if err != nil {
1864 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1865 return
1866 }
1867
1868 switch resp.StatusCode {
1869 case http.StatusConflict:
1870 s.pages.Notice(w, "repo", "A repository with that name already exists.")
1871 return
1872 case http.StatusInternalServerError:
1873 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1874 case http.StatusNoContent:
1875 // continue
1876 }
1877
1878 xrpcClient, _ := s.auth.AuthorizedClient(r)
1879
1880 createdAt := time.Now().Format(time.RFC3339)
1881 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
1882 Collection: tangled.RepoNSID,
1883 Repo: user.Did,
1884 Rkey: rkey,
1885 Record: &lexutil.LexiconTypeDecoder{
1886 Val: &tangled.Repo{
1887 Knot: repo.Knot,
1888 Name: repo.Name,
1889 CreatedAt: createdAt,
1890 Owner: user.Did,
1891 Source: &sourceAt,
1892 }},
1893 })
1894 if err != nil {
1895 log.Printf("failed to create record: %s", err)
1896 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1897 return
1898 }
1899 log.Println("created repo record: ", atresp.Uri)
1900
1901 repo.AtUri = atresp.Uri
1902 err = db.AddRepo(tx, repo)
1903 if err != nil {
1904 log.Println(err)
1905 s.pages.Notice(w, "repo", "Failed to save repository information.")
1906 return
1907 }
1908
1909 // acls
1910 p, _ := securejoin.SecureJoin(user.Did, forkName)
1911 err = s.enforcer.AddRepo(user.Did, knot, p)
1912 if err != nil {
1913 log.Println(err)
1914 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1915 return
1916 }
1917
1918 err = tx.Commit()
1919 if err != nil {
1920 log.Println("failed to commit changes", err)
1921 http.Error(w, err.Error(), http.StatusInternalServerError)
1922 return
1923 }
1924
1925 err = s.enforcer.E.SavePolicy()
1926 if err != nil {
1927 log.Println("failed to update ACLs", err)
1928 http.Error(w, err.Error(), http.StatusInternalServerError)
1929 return
1930 }
1931
1932 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1933 return
1934 }
1935}