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