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