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