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