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