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