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