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