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