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