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