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}
950
951func (f *FullyResolvedRepo) OwnerDid() string {
952 return f.OwnerId.DID.String()
953}
954
955func (f *FullyResolvedRepo) OwnerHandle() string {
956 return f.OwnerId.Handle.String()
957}
958
959func (f *FullyResolvedRepo) OwnerSlashRepo() string {
960 handle := f.OwnerId.Handle
961
962 var p string
963 if handle != "" && !handle.IsInvalidHandle() {
964 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
965 } else {
966 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
967 }
968
969 return p
970}
971
972func (f *FullyResolvedRepo) DidSlashRepo() string {
973 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
974 return p
975}
976
977func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
978 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
979 if err != nil {
980 return nil, err
981 }
982
983 var collaborators []pages.Collaborator
984 for _, item := range repoCollaborators {
985 // currently only two roles: owner and member
986 var role string
987 if item[3] == "repo:owner" {
988 role = "owner"
989 } else if item[3] == "repo:collaborator" {
990 role = "collaborator"
991 } else {
992 continue
993 }
994
995 did := item[0]
996
997 c := pages.Collaborator{
998 Did: did,
999 Handle: "",
1000 Role: role,
1001 }
1002 collaborators = append(collaborators, c)
1003 }
1004
1005 // populate all collborators with handles
1006 identsToResolve := make([]string, len(collaborators))
1007 for i, collab := range collaborators {
1008 identsToResolve[i] = collab.Did
1009 }
1010
1011 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
1012 for i, resolved := range resolvedIdents {
1013 if resolved != nil {
1014 collaborators[i].Handle = resolved.Handle.String()
1015 }
1016 }
1017
1018 return collaborators, nil
1019}
1020
1021func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo {
1022 isStarred := false
1023 if u != nil {
1024 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
1025 }
1026
1027 starCount, err := db.GetStarCount(s.db, f.RepoAt)
1028 if err != nil {
1029 log.Println("failed to get star count for ", f.RepoAt)
1030 }
1031 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
1032 if err != nil {
1033 log.Println("failed to get issue count for ", f.RepoAt)
1034 }
1035 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
1036 if err != nil {
1037 log.Println("failed to get issue count for ", f.RepoAt)
1038 }
1039 source, err := db.GetRepoSource(s.db, f.RepoAt)
1040 if errors.Is(err, sql.ErrNoRows) {
1041 source = ""
1042 } else if err != nil {
1043 log.Println("failed to get repo source for ", f.RepoAt, err)
1044 }
1045
1046 var sourceRepo *db.Repo
1047 if source != "" {
1048 sourceRepo, err = db.GetRepoByAtUri(s.db, source)
1049 if err != nil {
1050 log.Println("failed to get repo by at uri", err)
1051 }
1052 }
1053
1054 var sourceHandle *identity.Identity
1055 if sourceRepo != nil {
1056 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
1057 if err != nil {
1058 log.Println("failed to resolve source repo", err)
1059 }
1060 }
1061
1062 knot := f.Knot
1063 var disableFork bool
1064 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
1065 if err != nil {
1066 log.Printf("failed to create unsigned client for %s: %v", knot, err)
1067 } else {
1068 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1069 if err != nil {
1070 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1071 } else {
1072 defer resp.Body.Close()
1073 body, err := io.ReadAll(resp.Body)
1074 if err != nil {
1075 log.Printf("error reading branch response body: %v", err)
1076 } else {
1077 var branchesResp types.RepoBranchesResponse
1078 if err := json.Unmarshal(body, &branchesResp); err != nil {
1079 log.Printf("error parsing branch response: %v", err)
1080 } else {
1081 disableFork = false
1082 }
1083
1084 if len(branchesResp.Branches) == 0 {
1085 disableFork = true
1086 }
1087 }
1088 }
1089 }
1090
1091 repoInfo := repoinfo.RepoInfo{
1092 OwnerDid: f.OwnerDid(),
1093 OwnerHandle: f.OwnerHandle(),
1094 Name: f.RepoName,
1095 RepoAt: f.RepoAt,
1096 Description: f.Description,
1097 Ref: f.Ref,
1098 IsStarred: isStarred,
1099 Knot: knot,
1100 Roles: RolesInRepo(s, u, f),
1101 Stats: db.RepoStats{
1102 StarCount: starCount,
1103 IssueCount: issueCount,
1104 PullCount: pullCount,
1105 },
1106 DisableFork: disableFork,
1107 }
1108
1109 if sourceRepo != nil {
1110 repoInfo.Source = sourceRepo
1111 repoInfo.SourceHandle = sourceHandle.Handle.String()
1112 }
1113
1114 return repoInfo
1115}
1116
1117func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1118 user := s.oauth.GetUser(r)
1119 f, err := s.fullyResolvedRepo(r)
1120 if err != nil {
1121 log.Println("failed to get repo and knot", err)
1122 return
1123 }
1124
1125 issueId := chi.URLParam(r, "issue")
1126 issueIdInt, err := strconv.Atoi(issueId)
1127 if err != nil {
1128 http.Error(w, "bad issue id", http.StatusBadRequest)
1129 log.Println("failed to parse issue id", err)
1130 return
1131 }
1132
1133 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1134 if err != nil {
1135 log.Println("failed to get issue and comments", err)
1136 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1137 return
1138 }
1139
1140 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1141 if err != nil {
1142 log.Println("failed to resolve issue owner", err)
1143 }
1144
1145 identsToResolve := make([]string, len(comments))
1146 for i, comment := range comments {
1147 identsToResolve[i] = comment.OwnerDid
1148 }
1149 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1150 didHandleMap := make(map[string]string)
1151 for _, identity := range resolvedIds {
1152 if !identity.Handle.IsInvalidHandle() {
1153 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1154 } else {
1155 didHandleMap[identity.DID.String()] = identity.DID.String()
1156 }
1157 }
1158
1159 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1160 LoggedInUser: user,
1161 RepoInfo: f.RepoInfo(s, user),
1162 Issue: *issue,
1163 Comments: comments,
1164
1165 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1166 DidHandleMap: didHandleMap,
1167 })
1168
1169}
1170
1171func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1172 user := s.oauth.GetUser(r)
1173 f, err := s.fullyResolvedRepo(r)
1174 if err != nil {
1175 log.Println("failed to get repo and knot", err)
1176 return
1177 }
1178
1179 issueId := chi.URLParam(r, "issue")
1180 issueIdInt, err := strconv.Atoi(issueId)
1181 if err != nil {
1182 http.Error(w, "bad issue id", http.StatusBadRequest)
1183 log.Println("failed to parse issue id", err)
1184 return
1185 }
1186
1187 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1188 if err != nil {
1189 log.Println("failed to get issue", err)
1190 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1191 return
1192 }
1193
1194 collaborators, err := f.Collaborators(r.Context(), s)
1195 if err != nil {
1196 log.Println("failed to fetch repo collaborators: %w", err)
1197 }
1198 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1199 return user.Did == collab.Did
1200 })
1201 isIssueOwner := user.Did == issue.OwnerDid
1202
1203 // TODO: make this more granular
1204 if isIssueOwner || isCollaborator {
1205
1206 closed := tangled.RepoIssueStateClosed
1207
1208 client, err := s.oauth.AuthorizedClient(r)
1209 if err != nil {
1210 log.Println("failed to get authorized client", err)
1211 return
1212 }
1213 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1214 Collection: tangled.RepoIssueStateNSID,
1215 Repo: user.Did,
1216 Rkey: appview.TID(),
1217 Record: &lexutil.LexiconTypeDecoder{
1218 Val: &tangled.RepoIssueState{
1219 Issue: issue.IssueAt,
1220 State: closed,
1221 },
1222 },
1223 })
1224
1225 if err != nil {
1226 log.Println("failed to update issue state", err)
1227 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1228 return
1229 }
1230
1231 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1232 if err != nil {
1233 log.Println("failed to close issue", err)
1234 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1235 return
1236 }
1237
1238 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1239 return
1240 } else {
1241 log.Println("user is not permitted to close issue")
1242 http.Error(w, "for biden", http.StatusUnauthorized)
1243 return
1244 }
1245}
1246
1247func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1248 user := s.oauth.GetUser(r)
1249 f, err := s.fullyResolvedRepo(r)
1250 if err != nil {
1251 log.Println("failed to get repo and knot", err)
1252 return
1253 }
1254
1255 issueId := chi.URLParam(r, "issue")
1256 issueIdInt, err := strconv.Atoi(issueId)
1257 if err != nil {
1258 http.Error(w, "bad issue id", http.StatusBadRequest)
1259 log.Println("failed to parse issue id", err)
1260 return
1261 }
1262
1263 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1264 if err != nil {
1265 log.Println("failed to get issue", err)
1266 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1267 return
1268 }
1269
1270 collaborators, err := f.Collaborators(r.Context(), s)
1271 if err != nil {
1272 log.Println("failed to fetch repo collaborators: %w", err)
1273 }
1274 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1275 return user.Did == collab.Did
1276 })
1277 isIssueOwner := user.Did == issue.OwnerDid
1278
1279 if isCollaborator || isIssueOwner {
1280 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1281 if err != nil {
1282 log.Println("failed to reopen issue", err)
1283 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1284 return
1285 }
1286 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1287 return
1288 } else {
1289 log.Println("user is not the owner of the repo")
1290 http.Error(w, "forbidden", http.StatusUnauthorized)
1291 return
1292 }
1293}
1294
1295func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1296 user := s.oauth.GetUser(r)
1297 f, err := s.fullyResolvedRepo(r)
1298 if err != nil {
1299 log.Println("failed to get repo and knot", err)
1300 return
1301 }
1302
1303 issueId := chi.URLParam(r, "issue")
1304 issueIdInt, err := strconv.Atoi(issueId)
1305 if err != nil {
1306 http.Error(w, "bad issue id", http.StatusBadRequest)
1307 log.Println("failed to parse issue id", err)
1308 return
1309 }
1310
1311 switch r.Method {
1312 case http.MethodPost:
1313 body := r.FormValue("body")
1314 if body == "" {
1315 s.pages.Notice(w, "issue", "Body is required")
1316 return
1317 }
1318
1319 commentId := mathrand.IntN(1000000)
1320 rkey := appview.TID()
1321
1322 err := db.NewIssueComment(s.db, &db.Comment{
1323 OwnerDid: user.Did,
1324 RepoAt: f.RepoAt,
1325 Issue: issueIdInt,
1326 CommentId: commentId,
1327 Body: body,
1328 Rkey: rkey,
1329 })
1330 if err != nil {
1331 log.Println("failed to create comment", err)
1332 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1333 return
1334 }
1335
1336 createdAt := time.Now().Format(time.RFC3339)
1337 commentIdInt64 := int64(commentId)
1338 ownerDid := user.Did
1339 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1340 if err != nil {
1341 log.Println("failed to get issue at", err)
1342 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1343 return
1344 }
1345
1346 atUri := f.RepoAt.String()
1347 client, err := s.oauth.AuthorizedClient(r)
1348 if err != nil {
1349 log.Println("failed to get authorized client", err)
1350 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1351 return
1352 }
1353 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1354 Collection: tangled.RepoIssueCommentNSID,
1355 Repo: user.Did,
1356 Rkey: rkey,
1357 Record: &lexutil.LexiconTypeDecoder{
1358 Val: &tangled.RepoIssueComment{
1359 Repo: &atUri,
1360 Issue: issueAt,
1361 CommentId: &commentIdInt64,
1362 Owner: &ownerDid,
1363 Body: body,
1364 CreatedAt: createdAt,
1365 },
1366 },
1367 })
1368 if err != nil {
1369 log.Println("failed to create comment", err)
1370 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1371 return
1372 }
1373
1374 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1375 return
1376 }
1377}
1378
1379func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1380 user := s.oauth.GetUser(r)
1381 f, err := s.fullyResolvedRepo(r)
1382 if err != nil {
1383 log.Println("failed to get repo and knot", err)
1384 return
1385 }
1386
1387 issueId := chi.URLParam(r, "issue")
1388 issueIdInt, err := strconv.Atoi(issueId)
1389 if err != nil {
1390 http.Error(w, "bad issue id", http.StatusBadRequest)
1391 log.Println("failed to parse issue id", err)
1392 return
1393 }
1394
1395 commentId := chi.URLParam(r, "comment_id")
1396 commentIdInt, err := strconv.Atoi(commentId)
1397 if err != nil {
1398 http.Error(w, "bad comment id", http.StatusBadRequest)
1399 log.Println("failed to parse issue id", err)
1400 return
1401 }
1402
1403 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1404 if err != nil {
1405 log.Println("failed to get issue", err)
1406 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1407 return
1408 }
1409
1410 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1411 if err != nil {
1412 http.Error(w, "bad comment id", http.StatusBadRequest)
1413 return
1414 }
1415
1416 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1417 if err != nil {
1418 log.Println("failed to resolve did")
1419 return
1420 }
1421
1422 didHandleMap := make(map[string]string)
1423 if !identity.Handle.IsInvalidHandle() {
1424 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1425 } else {
1426 didHandleMap[identity.DID.String()] = identity.DID.String()
1427 }
1428
1429 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1430 LoggedInUser: user,
1431 RepoInfo: f.RepoInfo(s, user),
1432 DidHandleMap: didHandleMap,
1433 Issue: issue,
1434 Comment: comment,
1435 })
1436}
1437
1438func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1439 user := s.oauth.GetUser(r)
1440 f, err := s.fullyResolvedRepo(r)
1441 if err != nil {
1442 log.Println("failed to get repo and knot", err)
1443 return
1444 }
1445
1446 issueId := chi.URLParam(r, "issue")
1447 issueIdInt, err := strconv.Atoi(issueId)
1448 if err != nil {
1449 http.Error(w, "bad issue id", http.StatusBadRequest)
1450 log.Println("failed to parse issue id", err)
1451 return
1452 }
1453
1454 commentId := chi.URLParam(r, "comment_id")
1455 commentIdInt, err := strconv.Atoi(commentId)
1456 if err != nil {
1457 http.Error(w, "bad comment id", http.StatusBadRequest)
1458 log.Println("failed to parse issue id", err)
1459 return
1460 }
1461
1462 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1463 if err != nil {
1464 log.Println("failed to get issue", err)
1465 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1466 return
1467 }
1468
1469 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1470 if err != nil {
1471 http.Error(w, "bad comment id", http.StatusBadRequest)
1472 return
1473 }
1474
1475 if comment.OwnerDid != user.Did {
1476 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1477 return
1478 }
1479
1480 switch r.Method {
1481 case http.MethodGet:
1482 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1483 LoggedInUser: user,
1484 RepoInfo: f.RepoInfo(s, user),
1485 Issue: issue,
1486 Comment: comment,
1487 })
1488 case http.MethodPost:
1489 // extract form value
1490 newBody := r.FormValue("body")
1491 client, err := s.oauth.AuthorizedClient(r)
1492 if err != nil {
1493 log.Println("failed to get authorized client", err)
1494 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1495 return
1496 }
1497 rkey := comment.Rkey
1498
1499 // optimistic update
1500 edited := time.Now()
1501 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1502 if err != nil {
1503 log.Println("failed to perferom update-description query", err)
1504 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1505 return
1506 }
1507
1508 // rkey is optional, it was introduced later
1509 if comment.Rkey != "" {
1510 // update the record on pds
1511 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1512 if err != nil {
1513 // failed to get record
1514 log.Println(err, rkey)
1515 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1516 return
1517 }
1518 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1519 record, _ := data.UnmarshalJSON(value)
1520
1521 repoAt := record["repo"].(string)
1522 issueAt := record["issue"].(string)
1523 createdAt := record["createdAt"].(string)
1524 commentIdInt64 := int64(commentIdInt)
1525
1526 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1527 Collection: tangled.RepoIssueCommentNSID,
1528 Repo: user.Did,
1529 Rkey: rkey,
1530 SwapRecord: ex.Cid,
1531 Record: &lexutil.LexiconTypeDecoder{
1532 Val: &tangled.RepoIssueComment{
1533 Repo: &repoAt,
1534 Issue: issueAt,
1535 CommentId: &commentIdInt64,
1536 Owner: &comment.OwnerDid,
1537 Body: newBody,
1538 CreatedAt: createdAt,
1539 },
1540 },
1541 })
1542 if err != nil {
1543 log.Println(err)
1544 }
1545 }
1546
1547 // optimistic update for htmx
1548 didHandleMap := map[string]string{
1549 user.Did: user.Handle,
1550 }
1551 comment.Body = newBody
1552 comment.Edited = &edited
1553
1554 // return new comment body with htmx
1555 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1556 LoggedInUser: user,
1557 RepoInfo: f.RepoInfo(s, user),
1558 DidHandleMap: didHandleMap,
1559 Issue: issue,
1560 Comment: comment,
1561 })
1562 return
1563
1564 }
1565
1566}
1567
1568func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1569 user := s.oauth.GetUser(r)
1570 f, err := s.fullyResolvedRepo(r)
1571 if err != nil {
1572 log.Println("failed to get repo and knot", err)
1573 return
1574 }
1575
1576 issueId := chi.URLParam(r, "issue")
1577 issueIdInt, err := strconv.Atoi(issueId)
1578 if err != nil {
1579 http.Error(w, "bad issue id", http.StatusBadRequest)
1580 log.Println("failed to parse issue id", err)
1581 return
1582 }
1583
1584 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1585 if err != nil {
1586 log.Println("failed to get issue", err)
1587 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1588 return
1589 }
1590
1591 commentId := chi.URLParam(r, "comment_id")
1592 commentIdInt, err := strconv.Atoi(commentId)
1593 if err != nil {
1594 http.Error(w, "bad comment id", http.StatusBadRequest)
1595 log.Println("failed to parse issue id", err)
1596 return
1597 }
1598
1599 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1600 if err != nil {
1601 http.Error(w, "bad comment id", http.StatusBadRequest)
1602 return
1603 }
1604
1605 if comment.OwnerDid != user.Did {
1606 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1607 return
1608 }
1609
1610 if comment.Deleted != nil {
1611 http.Error(w, "comment already deleted", http.StatusBadRequest)
1612 return
1613 }
1614
1615 // optimistic deletion
1616 deleted := time.Now()
1617 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1618 if err != nil {
1619 log.Println("failed to delete comment")
1620 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1621 return
1622 }
1623
1624 // delete from pds
1625 if comment.Rkey != "" {
1626 client, err := s.oauth.AuthorizedClient(r)
1627 if err != nil {
1628 log.Println("failed to get authorized client", err)
1629 s.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1630 return
1631 }
1632 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1633 Collection: tangled.GraphFollowNSID,
1634 Repo: user.Did,
1635 Rkey: comment.Rkey,
1636 })
1637 if err != nil {
1638 log.Println(err)
1639 }
1640 }
1641
1642 // optimistic update for htmx
1643 didHandleMap := map[string]string{
1644 user.Did: user.Handle,
1645 }
1646 comment.Body = ""
1647 comment.Deleted = &deleted
1648
1649 // htmx fragment of comment after deletion
1650 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1651 LoggedInUser: user,
1652 RepoInfo: f.RepoInfo(s, user),
1653 DidHandleMap: didHandleMap,
1654 Issue: issue,
1655 Comment: comment,
1656 })
1657 return
1658}
1659
1660func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1661 params := r.URL.Query()
1662 state := params.Get("state")
1663 isOpen := true
1664 switch state {
1665 case "open":
1666 isOpen = true
1667 case "closed":
1668 isOpen = false
1669 default:
1670 isOpen = true
1671 }
1672
1673 page, ok := r.Context().Value("page").(pagination.Page)
1674 if !ok {
1675 log.Println("failed to get page")
1676 page = pagination.FirstPage()
1677 }
1678
1679 user := s.oauth.GetUser(r)
1680 f, err := s.fullyResolvedRepo(r)
1681 if err != nil {
1682 log.Println("failed to get repo and knot", err)
1683 return
1684 }
1685
1686 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1687 if err != nil {
1688 log.Println("failed to get issues", err)
1689 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1690 return
1691 }
1692
1693 identsToResolve := make([]string, len(issues))
1694 for i, issue := range issues {
1695 identsToResolve[i] = issue.OwnerDid
1696 }
1697 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1698 didHandleMap := make(map[string]string)
1699 for _, identity := range resolvedIds {
1700 if !identity.Handle.IsInvalidHandle() {
1701 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1702 } else {
1703 didHandleMap[identity.DID.String()] = identity.DID.String()
1704 }
1705 }
1706
1707 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1708 LoggedInUser: s.oauth.GetUser(r),
1709 RepoInfo: f.RepoInfo(s, user),
1710 Issues: issues,
1711 DidHandleMap: didHandleMap,
1712 FilteringByOpen: isOpen,
1713 Page: page,
1714 })
1715 return
1716}
1717
1718func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1719 user := s.oauth.GetUser(r)
1720
1721 f, err := s.fullyResolvedRepo(r)
1722 if err != nil {
1723 log.Println("failed to get repo and knot", err)
1724 return
1725 }
1726
1727 switch r.Method {
1728 case http.MethodGet:
1729 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1730 LoggedInUser: user,
1731 RepoInfo: f.RepoInfo(s, user),
1732 })
1733 case http.MethodPost:
1734 title := r.FormValue("title")
1735 body := r.FormValue("body")
1736
1737 if title == "" || body == "" {
1738 s.pages.Notice(w, "issues", "Title and body are required")
1739 return
1740 }
1741
1742 tx, err := s.db.BeginTx(r.Context(), nil)
1743 if err != nil {
1744 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1745 return
1746 }
1747
1748 err = db.NewIssue(tx, &db.Issue{
1749 RepoAt: f.RepoAt,
1750 Title: title,
1751 Body: body,
1752 OwnerDid: user.Did,
1753 })
1754 if err != nil {
1755 log.Println("failed to create issue", err)
1756 s.pages.Notice(w, "issues", "Failed to create issue.")
1757 return
1758 }
1759
1760 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1761 if err != nil {
1762 log.Println("failed to get issue id", err)
1763 s.pages.Notice(w, "issues", "Failed to create issue.")
1764 return
1765 }
1766
1767 client, err := s.oauth.AuthorizedClient(r)
1768 if err != nil {
1769 log.Println("failed to get authorized client", err)
1770 s.pages.Notice(w, "issues", "Failed to create issue.")
1771 return
1772 }
1773 atUri := f.RepoAt.String()
1774 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1775 Collection: tangled.RepoIssueNSID,
1776 Repo: user.Did,
1777 Rkey: appview.TID(),
1778 Record: &lexutil.LexiconTypeDecoder{
1779 Val: &tangled.RepoIssue{
1780 Repo: atUri,
1781 Title: title,
1782 Body: &body,
1783 Owner: user.Did,
1784 IssueId: int64(issueId),
1785 },
1786 },
1787 })
1788 if err != nil {
1789 log.Println("failed to create issue", err)
1790 s.pages.Notice(w, "issues", "Failed to create issue.")
1791 return
1792 }
1793
1794 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1795 if err != nil {
1796 log.Println("failed to set issue at", err)
1797 s.pages.Notice(w, "issues", "Failed to create issue.")
1798 return
1799 }
1800
1801 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1802 return
1803 }
1804}
1805
1806func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1807 user := s.oauth.GetUser(r)
1808 f, err := s.fullyResolvedRepo(r)
1809 if err != nil {
1810 log.Printf("failed to resolve source repo: %v", err)
1811 return
1812 }
1813
1814 switch r.Method {
1815 case http.MethodGet:
1816 user := s.oauth.GetUser(r)
1817 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1818 if err != nil {
1819 s.pages.Notice(w, "repo", "Invalid user account.")
1820 return
1821 }
1822
1823 s.pages.ForkRepo(w, pages.ForkRepoParams{
1824 LoggedInUser: user,
1825 Knots: knots,
1826 RepoInfo: f.RepoInfo(s, user),
1827 })
1828
1829 case http.MethodPost:
1830
1831 knot := r.FormValue("knot")
1832 if knot == "" {
1833 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1834 return
1835 }
1836
1837 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1838 if err != nil || !ok {
1839 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1840 return
1841 }
1842
1843 forkName := fmt.Sprintf("%s", f.RepoName)
1844
1845 // this check is *only* to see if the forked repo name already exists
1846 // in the user's account.
1847 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1848 if err != nil {
1849 if errors.Is(err, sql.ErrNoRows) {
1850 // no existing repo with this name found, we can use the name as is
1851 } else {
1852 log.Println("error fetching existing repo from db", err)
1853 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1854 return
1855 }
1856 } else if existingRepo != nil {
1857 // repo with this name already exists, append random string
1858 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1859 }
1860 secret, err := db.GetRegistrationKey(s.db, knot)
1861 if err != nil {
1862 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1863 return
1864 }
1865
1866 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev)
1867 if err != nil {
1868 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1869 return
1870 }
1871
1872 var uri string
1873 if s.config.Core.Dev {
1874 uri = "http"
1875 } else {
1876 uri = "https"
1877 }
1878 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1879 sourceAt := f.RepoAt.String()
1880
1881 rkey := appview.TID()
1882 repo := &db.Repo{
1883 Did: user.Did,
1884 Name: forkName,
1885 Knot: knot,
1886 Rkey: rkey,
1887 Source: sourceAt,
1888 }
1889
1890 tx, err := s.db.BeginTx(r.Context(), nil)
1891 if err != nil {
1892 log.Println(err)
1893 s.pages.Notice(w, "repo", "Failed to save repository information.")
1894 return
1895 }
1896 defer func() {
1897 tx.Rollback()
1898 err = s.enforcer.E.LoadPolicy()
1899 if err != nil {
1900 log.Println("failed to rollback policies")
1901 }
1902 }()
1903
1904 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1905 if err != nil {
1906 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1907 return
1908 }
1909
1910 switch resp.StatusCode {
1911 case http.StatusConflict:
1912 s.pages.Notice(w, "repo", "A repository with that name already exists.")
1913 return
1914 case http.StatusInternalServerError:
1915 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1916 case http.StatusNoContent:
1917 // continue
1918 }
1919
1920 xrpcClient, err := s.oauth.AuthorizedClient(r)
1921 if err != nil {
1922 log.Println("failed to get authorized client", err)
1923 s.pages.Notice(w, "repo", "Failed to create repository.")
1924 return
1925 }
1926
1927 createdAt := time.Now().Format(time.RFC3339)
1928 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1929 Collection: tangled.RepoNSID,
1930 Repo: user.Did,
1931 Rkey: rkey,
1932 Record: &lexutil.LexiconTypeDecoder{
1933 Val: &tangled.Repo{
1934 Knot: repo.Knot,
1935 Name: repo.Name,
1936 CreatedAt: createdAt,
1937 Owner: user.Did,
1938 Source: &sourceAt,
1939 }},
1940 })
1941 if err != nil {
1942 log.Printf("failed to create record: %s", err)
1943 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1944 return
1945 }
1946 log.Println("created repo record: ", atresp.Uri)
1947
1948 repo.AtUri = atresp.Uri
1949 err = db.AddRepo(tx, repo)
1950 if err != nil {
1951 log.Println(err)
1952 s.pages.Notice(w, "repo", "Failed to save repository information.")
1953 return
1954 }
1955
1956 // acls
1957 p, _ := securejoin.SecureJoin(user.Did, forkName)
1958 err = s.enforcer.AddRepo(user.Did, knot, p)
1959 if err != nil {
1960 log.Println(err)
1961 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1962 return
1963 }
1964
1965 err = tx.Commit()
1966 if err != nil {
1967 log.Println("failed to commit changes", err)
1968 http.Error(w, err.Error(), http.StatusInternalServerError)
1969 return
1970 }
1971
1972 err = s.enforcer.E.SavePolicy()
1973 if err != nil {
1974 log.Println("failed to update ACLs", err)
1975 http.Error(w, err.Error(), http.StatusInternalServerError)
1976 return
1977 }
1978
1979 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1980 return
1981 }
1982}