this repo has no description
1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 mathrand "math/rand/v2"
12 "net/http"
13 "path"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "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)
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 knot := f.Knot
822 if knot == "knot1.tangled.sh" {
823 knot = "tangled.sh"
824 }
825
826 var sourceHandle *identity.Identity
827 if sourceRepo != nil {
828 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
829 if err != nil {
830 log.Println("failed to resolve source repo", err)
831 }
832 }
833
834 return 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 Source: sourceRepo,
849 SourceHandle: sourceHandle.Handle.String(),
850 }
851}
852
853func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
854 user := s.auth.GetUser(r)
855 f, err := fullyResolvedRepo(r)
856 if err != nil {
857 log.Println("failed to get repo and knot", err)
858 return
859 }
860
861 issueId := chi.URLParam(r, "issue")
862 issueIdInt, err := strconv.Atoi(issueId)
863 if err != nil {
864 http.Error(w, "bad issue id", http.StatusBadRequest)
865 log.Println("failed to parse issue id", err)
866 return
867 }
868
869 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
870 if err != nil {
871 log.Println("failed to get issue and comments", err)
872 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
873 return
874 }
875
876 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
877 if err != nil {
878 log.Println("failed to resolve issue owner", err)
879 }
880
881 identsToResolve := make([]string, len(comments))
882 for i, comment := range comments {
883 identsToResolve[i] = comment.OwnerDid
884 }
885 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
886 didHandleMap := make(map[string]string)
887 for _, identity := range resolvedIds {
888 if !identity.Handle.IsInvalidHandle() {
889 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
890 } else {
891 didHandleMap[identity.DID.String()] = identity.DID.String()
892 }
893 }
894
895 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
896 LoggedInUser: user,
897 RepoInfo: f.RepoInfo(s, user),
898 Issue: *issue,
899 Comments: comments,
900
901 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
902 DidHandleMap: didHandleMap,
903 })
904
905}
906
907func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
908 user := s.auth.GetUser(r)
909 f, err := fullyResolvedRepo(r)
910 if err != nil {
911 log.Println("failed to get repo and knot", err)
912 return
913 }
914
915 issueId := chi.URLParam(r, "issue")
916 issueIdInt, err := strconv.Atoi(issueId)
917 if err != nil {
918 http.Error(w, "bad issue id", http.StatusBadRequest)
919 log.Println("failed to parse issue id", err)
920 return
921 }
922
923 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
924 if err != nil {
925 log.Println("failed to get issue", err)
926 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
927 return
928 }
929
930 collaborators, err := f.Collaborators(r.Context(), s)
931 if err != nil {
932 log.Println("failed to fetch repo collaborators: %w", err)
933 }
934 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
935 return user.Did == collab.Did
936 })
937 isIssueOwner := user.Did == issue.OwnerDid
938
939 // TODO: make this more granular
940 if isIssueOwner || isCollaborator {
941
942 closed := tangled.RepoIssueStateClosed
943
944 client, _ := s.auth.AuthorizedClient(r)
945 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
946 Collection: tangled.RepoIssueStateNSID,
947 Repo: user.Did,
948 Rkey: s.TID(),
949 Record: &lexutil.LexiconTypeDecoder{
950 Val: &tangled.RepoIssueState{
951 Issue: issue.IssueAt,
952 State: &closed,
953 },
954 },
955 })
956
957 if err != nil {
958 log.Println("failed to update issue state", err)
959 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
960 return
961 }
962
963 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
964 if err != nil {
965 log.Println("failed to close issue", err)
966 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
967 return
968 }
969
970 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
971 return
972 } else {
973 log.Println("user is not permitted to close issue")
974 http.Error(w, "for biden", http.StatusUnauthorized)
975 return
976 }
977}
978
979func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
980 user := s.auth.GetUser(r)
981 f, err := fullyResolvedRepo(r)
982 if err != nil {
983 log.Println("failed to get repo and knot", err)
984 return
985 }
986
987 issueId := chi.URLParam(r, "issue")
988 issueIdInt, err := strconv.Atoi(issueId)
989 if err != nil {
990 http.Error(w, "bad issue id", http.StatusBadRequest)
991 log.Println("failed to parse issue id", err)
992 return
993 }
994
995 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
996 if err != nil {
997 log.Println("failed to get issue", err)
998 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
999 return
1000 }
1001
1002 collaborators, err := f.Collaborators(r.Context(), s)
1003 if err != nil {
1004 log.Println("failed to fetch repo collaborators: %w", err)
1005 }
1006 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1007 return user.Did == collab.Did
1008 })
1009 isIssueOwner := user.Did == issue.OwnerDid
1010
1011 if isCollaborator || isIssueOwner {
1012 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1013 if err != nil {
1014 log.Println("failed to reopen issue", err)
1015 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1016 return
1017 }
1018 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1019 return
1020 } else {
1021 log.Println("user is not the owner of the repo")
1022 http.Error(w, "forbidden", http.StatusUnauthorized)
1023 return
1024 }
1025}
1026
1027func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1028 user := s.auth.GetUser(r)
1029 f, err := fullyResolvedRepo(r)
1030 if err != nil {
1031 log.Println("failed to get repo and knot", err)
1032 return
1033 }
1034
1035 issueId := chi.URLParam(r, "issue")
1036 issueIdInt, err := strconv.Atoi(issueId)
1037 if err != nil {
1038 http.Error(w, "bad issue id", http.StatusBadRequest)
1039 log.Println("failed to parse issue id", err)
1040 return
1041 }
1042
1043 switch r.Method {
1044 case http.MethodPost:
1045 body := r.FormValue("body")
1046 if body == "" {
1047 s.pages.Notice(w, "issue", "Body is required")
1048 return
1049 }
1050
1051 commentId := mathrand.IntN(1000000)
1052 rkey := s.TID()
1053
1054 err := db.NewIssueComment(s.db, &db.Comment{
1055 OwnerDid: user.Did,
1056 RepoAt: f.RepoAt,
1057 Issue: issueIdInt,
1058 CommentId: commentId,
1059 Body: body,
1060 Rkey: rkey,
1061 })
1062 if err != nil {
1063 log.Println("failed to create comment", err)
1064 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1065 return
1066 }
1067
1068 createdAt := time.Now().Format(time.RFC3339)
1069 commentIdInt64 := int64(commentId)
1070 ownerDid := user.Did
1071 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1072 if err != nil {
1073 log.Println("failed to get issue at", err)
1074 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1075 return
1076 }
1077
1078 atUri := f.RepoAt.String()
1079 client, _ := s.auth.AuthorizedClient(r)
1080 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1081 Collection: tangled.RepoIssueCommentNSID,
1082 Repo: user.Did,
1083 Rkey: rkey,
1084 Record: &lexutil.LexiconTypeDecoder{
1085 Val: &tangled.RepoIssueComment{
1086 Repo: &atUri,
1087 Issue: issueAt,
1088 CommentId: &commentIdInt64,
1089 Owner: &ownerDid,
1090 Body: &body,
1091 CreatedAt: &createdAt,
1092 },
1093 },
1094 })
1095 if err != nil {
1096 log.Println("failed to create comment", err)
1097 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1098 return
1099 }
1100
1101 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1102 return
1103 }
1104}
1105
1106func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1107 user := s.auth.GetUser(r)
1108 f, err := fullyResolvedRepo(r)
1109 if err != nil {
1110 log.Println("failed to get repo and knot", err)
1111 return
1112 }
1113
1114 issueId := chi.URLParam(r, "issue")
1115 issueIdInt, err := strconv.Atoi(issueId)
1116 if err != nil {
1117 http.Error(w, "bad issue id", http.StatusBadRequest)
1118 log.Println("failed to parse issue id", err)
1119 return
1120 }
1121
1122 commentId := chi.URLParam(r, "comment_id")
1123 commentIdInt, err := strconv.Atoi(commentId)
1124 if err != nil {
1125 http.Error(w, "bad comment id", http.StatusBadRequest)
1126 log.Println("failed to parse issue id", err)
1127 return
1128 }
1129
1130 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1131 if err != nil {
1132 log.Println("failed to get issue", err)
1133 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1134 return
1135 }
1136
1137 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1138 if err != nil {
1139 http.Error(w, "bad comment id", http.StatusBadRequest)
1140 return
1141 }
1142
1143 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1144 if err != nil {
1145 log.Println("failed to resolve did")
1146 return
1147 }
1148
1149 didHandleMap := make(map[string]string)
1150 if !identity.Handle.IsInvalidHandle() {
1151 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1152 } else {
1153 didHandleMap[identity.DID.String()] = identity.DID.String()
1154 }
1155
1156 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1157 LoggedInUser: user,
1158 RepoInfo: f.RepoInfo(s, user),
1159 DidHandleMap: didHandleMap,
1160 Issue: issue,
1161 Comment: comment,
1162 })
1163}
1164
1165func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1166 user := s.auth.GetUser(r)
1167 f, err := fullyResolvedRepo(r)
1168 if err != nil {
1169 log.Println("failed to get repo and knot", err)
1170 return
1171 }
1172
1173 issueId := chi.URLParam(r, "issue")
1174 issueIdInt, err := strconv.Atoi(issueId)
1175 if err != nil {
1176 http.Error(w, "bad issue id", http.StatusBadRequest)
1177 log.Println("failed to parse issue id", err)
1178 return
1179 }
1180
1181 commentId := chi.URLParam(r, "comment_id")
1182 commentIdInt, err := strconv.Atoi(commentId)
1183 if err != nil {
1184 http.Error(w, "bad comment id", http.StatusBadRequest)
1185 log.Println("failed to parse issue id", err)
1186 return
1187 }
1188
1189 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1190 if err != nil {
1191 log.Println("failed to get issue", err)
1192 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1193 return
1194 }
1195
1196 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1197 if err != nil {
1198 http.Error(w, "bad comment id", http.StatusBadRequest)
1199 return
1200 }
1201
1202 if comment.OwnerDid != user.Did {
1203 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1204 return
1205 }
1206
1207 switch r.Method {
1208 case http.MethodGet:
1209 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1210 LoggedInUser: user,
1211 RepoInfo: f.RepoInfo(s, user),
1212 Issue: issue,
1213 Comment: comment,
1214 })
1215 case http.MethodPost:
1216 // extract form value
1217 newBody := r.FormValue("body")
1218 client, _ := s.auth.AuthorizedClient(r)
1219 rkey := comment.Rkey
1220
1221 // optimistic update
1222 edited := time.Now()
1223 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1224 if err != nil {
1225 log.Println("failed to perferom update-description query", err)
1226 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1227 return
1228 }
1229
1230 // rkey is optional, it was introduced later
1231 if comment.Rkey != "" {
1232 // update the record on pds
1233 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1234 if err != nil {
1235 // failed to get record
1236 log.Println(err, rkey)
1237 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1238 return
1239 }
1240 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1241 record, _ := data.UnmarshalJSON(value)
1242
1243 repoAt := record["repo"].(string)
1244 issueAt := record["issue"].(string)
1245 createdAt := record["createdAt"].(string)
1246 commentIdInt64 := int64(commentIdInt)
1247
1248 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1249 Collection: tangled.RepoIssueCommentNSID,
1250 Repo: user.Did,
1251 Rkey: rkey,
1252 SwapRecord: ex.Cid,
1253 Record: &lexutil.LexiconTypeDecoder{
1254 Val: &tangled.RepoIssueComment{
1255 Repo: &repoAt,
1256 Issue: issueAt,
1257 CommentId: &commentIdInt64,
1258 Owner: &comment.OwnerDid,
1259 Body: &newBody,
1260 CreatedAt: &createdAt,
1261 },
1262 },
1263 })
1264 if err != nil {
1265 log.Println(err)
1266 }
1267 }
1268
1269 // optimistic update for htmx
1270 didHandleMap := map[string]string{
1271 user.Did: user.Handle,
1272 }
1273 comment.Body = newBody
1274 comment.Edited = &edited
1275
1276 // return new comment body with htmx
1277 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1278 LoggedInUser: user,
1279 RepoInfo: f.RepoInfo(s, user),
1280 DidHandleMap: didHandleMap,
1281 Issue: issue,
1282 Comment: comment,
1283 })
1284 return
1285
1286 }
1287
1288}
1289
1290func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1291 user := s.auth.GetUser(r)
1292 f, err := fullyResolvedRepo(r)
1293 if err != nil {
1294 log.Println("failed to get repo and knot", err)
1295 return
1296 }
1297
1298 issueId := chi.URLParam(r, "issue")
1299 issueIdInt, err := strconv.Atoi(issueId)
1300 if err != nil {
1301 http.Error(w, "bad issue id", http.StatusBadRequest)
1302 log.Println("failed to parse issue id", err)
1303 return
1304 }
1305
1306 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1307 if err != nil {
1308 log.Println("failed to get issue", err)
1309 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1310 return
1311 }
1312
1313 commentId := chi.URLParam(r, "comment_id")
1314 commentIdInt, err := strconv.Atoi(commentId)
1315 if err != nil {
1316 http.Error(w, "bad comment id", http.StatusBadRequest)
1317 log.Println("failed to parse issue id", err)
1318 return
1319 }
1320
1321 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1322 if err != nil {
1323 http.Error(w, "bad comment id", http.StatusBadRequest)
1324 return
1325 }
1326
1327 if comment.OwnerDid != user.Did {
1328 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1329 return
1330 }
1331
1332 if comment.Deleted != nil {
1333 http.Error(w, "comment already deleted", http.StatusBadRequest)
1334 return
1335 }
1336
1337 // optimistic deletion
1338 deleted := time.Now()
1339 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1340 if err != nil {
1341 log.Println("failed to delete comment")
1342 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1343 return
1344 }
1345
1346 // delete from pds
1347 if comment.Rkey != "" {
1348 client, _ := s.auth.AuthorizedClient(r)
1349 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1350 Collection: tangled.GraphFollowNSID,
1351 Repo: user.Did,
1352 Rkey: comment.Rkey,
1353 })
1354 if err != nil {
1355 log.Println(err)
1356 }
1357 }
1358
1359 // optimistic update for htmx
1360 didHandleMap := map[string]string{
1361 user.Did: user.Handle,
1362 }
1363 comment.Body = ""
1364 comment.Deleted = &deleted
1365
1366 // htmx fragment of comment after deletion
1367 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1368 LoggedInUser: user,
1369 RepoInfo: f.RepoInfo(s, user),
1370 DidHandleMap: didHandleMap,
1371 Issue: issue,
1372 Comment: comment,
1373 })
1374 return
1375}
1376
1377func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1378 params := r.URL.Query()
1379 state := params.Get("state")
1380 isOpen := true
1381 switch state {
1382 case "open":
1383 isOpen = true
1384 case "closed":
1385 isOpen = false
1386 default:
1387 isOpen = true
1388 }
1389
1390 user := s.auth.GetUser(r)
1391 f, err := fullyResolvedRepo(r)
1392 if err != nil {
1393 log.Println("failed to get repo and knot", err)
1394 return
1395 }
1396
1397 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1398 if err != nil {
1399 log.Println("failed to get issues", err)
1400 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1401 return
1402 }
1403
1404 identsToResolve := make([]string, len(issues))
1405 for i, issue := range issues {
1406 identsToResolve[i] = issue.OwnerDid
1407 }
1408 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1409 didHandleMap := make(map[string]string)
1410 for _, identity := range resolvedIds {
1411 if !identity.Handle.IsInvalidHandle() {
1412 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1413 } else {
1414 didHandleMap[identity.DID.String()] = identity.DID.String()
1415 }
1416 }
1417
1418 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1419 LoggedInUser: s.auth.GetUser(r),
1420 RepoInfo: f.RepoInfo(s, user),
1421 Issues: issues,
1422 DidHandleMap: didHandleMap,
1423 FilteringByOpen: isOpen,
1424 })
1425 return
1426}
1427
1428func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1429 user := s.auth.GetUser(r)
1430
1431 f, err := fullyResolvedRepo(r)
1432 if err != nil {
1433 log.Println("failed to get repo and knot", err)
1434 return
1435 }
1436
1437 switch r.Method {
1438 case http.MethodGet:
1439 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1440 LoggedInUser: user,
1441 RepoInfo: f.RepoInfo(s, user),
1442 })
1443 case http.MethodPost:
1444 title := r.FormValue("title")
1445 body := r.FormValue("body")
1446
1447 if title == "" || body == "" {
1448 s.pages.Notice(w, "issues", "Title and body are required")
1449 return
1450 }
1451
1452 tx, err := s.db.BeginTx(r.Context(), nil)
1453 if err != nil {
1454 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1455 return
1456 }
1457
1458 err = db.NewIssue(tx, &db.Issue{
1459 RepoAt: f.RepoAt,
1460 Title: title,
1461 Body: body,
1462 OwnerDid: user.Did,
1463 })
1464 if err != nil {
1465 log.Println("failed to create issue", err)
1466 s.pages.Notice(w, "issues", "Failed to create issue.")
1467 return
1468 }
1469
1470 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1471 if err != nil {
1472 log.Println("failed to get issue id", err)
1473 s.pages.Notice(w, "issues", "Failed to create issue.")
1474 return
1475 }
1476
1477 client, _ := s.auth.AuthorizedClient(r)
1478 atUri := f.RepoAt.String()
1479 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1480 Collection: tangled.RepoIssueNSID,
1481 Repo: user.Did,
1482 Rkey: s.TID(),
1483 Record: &lexutil.LexiconTypeDecoder{
1484 Val: &tangled.RepoIssue{
1485 Repo: atUri,
1486 Title: title,
1487 Body: &body,
1488 Owner: user.Did,
1489 IssueId: int64(issueId),
1490 },
1491 },
1492 })
1493 if err != nil {
1494 log.Println("failed to create issue", err)
1495 s.pages.Notice(w, "issues", "Failed to create issue.")
1496 return
1497 }
1498
1499 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1500 if err != nil {
1501 log.Println("failed to set issue at", err)
1502 s.pages.Notice(w, "issues", "Failed to create issue.")
1503 return
1504 }
1505
1506 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1507 return
1508 }
1509}
1510
1511func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1512 user := s.auth.GetUser(r)
1513 f, err := fullyResolvedRepo(r)
1514 if err != nil {
1515 log.Printf("failed to resolve source repo: %v", err)
1516 return
1517 }
1518
1519 switch r.Method {
1520 case http.MethodGet:
1521 user := s.auth.GetUser(r)
1522 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1523 if err != nil {
1524 s.pages.Notice(w, "repo", "Invalid user account.")
1525 return
1526 }
1527
1528 s.pages.ForkRepo(w, pages.ForkRepoParams{
1529 LoggedInUser: user,
1530 Knots: knots,
1531 RepoInfo: f.RepoInfo(s, user),
1532 })
1533
1534 case http.MethodPost:
1535
1536 knot := r.FormValue("knot")
1537 if knot == "" {
1538 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1539 return
1540 }
1541
1542 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1543 if err != nil || !ok {
1544 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1545 return
1546 }
1547
1548 forkName := fmt.Sprintf("%s", f.RepoName)
1549
1550 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1551 if err == nil && existingRepo != nil {
1552 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1553 }
1554
1555 secret, err := db.GetRegistrationKey(s.db, knot)
1556 if err != nil {
1557 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1558 return
1559 }
1560
1561 client, err := NewSignedClient(knot, secret, s.config.Dev)
1562 if err != nil {
1563 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
1564 return
1565 }
1566
1567 var uri string
1568 if s.config.Dev {
1569 uri = "http"
1570 } else {
1571 uri = "https"
1572 }
1573 sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, knot, f.OwnerDid(), f.RepoName)
1574 sourceAt := f.RepoAt.String()
1575
1576 rkey := s.TID()
1577 repo := &db.Repo{
1578 Did: user.Did,
1579 Name: forkName,
1580 Knot: knot,
1581 Rkey: rkey,
1582 Source: sourceAt,
1583 }
1584
1585 tx, err := s.db.BeginTx(r.Context(), nil)
1586 if err != nil {
1587 log.Println(err)
1588 s.pages.Notice(w, "repo", "Failed to save repository information.")
1589 return
1590 }
1591 defer func() {
1592 tx.Rollback()
1593 err = s.enforcer.E.LoadPolicy()
1594 if err != nil {
1595 log.Println("failed to rollback policies")
1596 }
1597 }()
1598
1599 resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
1600 if err != nil {
1601 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1602 return
1603 }
1604
1605 switch resp.StatusCode {
1606 case http.StatusConflict:
1607 s.pages.Notice(w, "repo", "A repository with that name already exists.")
1608 return
1609 case http.StatusInternalServerError:
1610 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1611 case http.StatusNoContent:
1612 // continue
1613 }
1614
1615 xrpcClient, _ := s.auth.AuthorizedClient(r)
1616
1617 addedAt := time.Now().Format(time.RFC3339)
1618 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
1619 Collection: tangled.RepoNSID,
1620 Repo: user.Did,
1621 Rkey: rkey,
1622 Record: &lexutil.LexiconTypeDecoder{
1623 Val: &tangled.Repo{
1624 Knot: repo.Knot,
1625 Name: repo.Name,
1626 AddedAt: &addedAt,
1627 Owner: user.Did,
1628 Source: &sourceAt,
1629 }},
1630 })
1631 if err != nil {
1632 log.Printf("failed to create record: %s", err)
1633 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1634 return
1635 }
1636 log.Println("created repo record: ", atresp.Uri)
1637
1638 repo.AtUri = atresp.Uri
1639 err = db.AddRepo(tx, repo)
1640 if err != nil {
1641 log.Println(err)
1642 s.pages.Notice(w, "repo", "Failed to save repository information.")
1643 return
1644 }
1645
1646 // acls
1647 p, _ := securejoin.SecureJoin(user.Did, forkName)
1648 err = s.enforcer.AddRepo(user.Did, knot, p)
1649 if err != nil {
1650 log.Println(err)
1651 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1652 return
1653 }
1654
1655 err = tx.Commit()
1656 if err != nil {
1657 log.Println("failed to commit changes", err)
1658 http.Error(w, err.Error(), http.StatusInternalServerError)
1659 return
1660 }
1661
1662 err = s.enforcer.E.SavePolicy()
1663 if err != nil {
1664 log.Println("failed to update ACLs", err)
1665 http.Error(w, err.Error(), http.StatusInternalServerError)
1666 return
1667 }
1668
1669 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1670 return
1671 }
1672}