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