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