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