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