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