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/%s/tree/%s", f.OwnerDid(), f.RepoName, 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.OwnerDid(), f.RepoName, "tree", ref, treePath)
330 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "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/%s/tree/%s", f.OwnerDid(), f.RepoName, 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 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
865 return p
866}
867
868func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
869 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
870 if err != nil {
871 return nil, err
872 }
873
874 var collaborators []pages.Collaborator
875 for _, item := range repoCollaborators {
876 // currently only two roles: owner and member
877 var role string
878 if item[3] == "repo:owner" {
879 role = "owner"
880 } else if item[3] == "repo:collaborator" {
881 role = "collaborator"
882 } else {
883 continue
884 }
885
886 did := item[0]
887
888 c := pages.Collaborator{
889 Did: did,
890 Handle: "",
891 Role: role,
892 }
893 collaborators = append(collaborators, c)
894 }
895
896 // populate all collborators with handles
897 identsToResolve := make([]string, len(collaborators))
898 for i, collab := range collaborators {
899 identsToResolve[i] = collab.Did
900 }
901
902 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
903 for i, resolved := range resolvedIdents {
904 if resolved != nil {
905 collaborators[i].Handle = resolved.Handle.String()
906 }
907 }
908
909 return collaborators, nil
910}
911
912func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
913 isStarred := false
914 if u != nil {
915 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
916 }
917
918 starCount, err := db.GetStarCount(s.db, f.RepoAt)
919 if err != nil {
920 log.Println("failed to get star count for ", f.RepoAt)
921 }
922 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
923 if err != nil {
924 log.Println("failed to get issue count for ", f.RepoAt)
925 }
926 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
927 if err != nil {
928 log.Println("failed to get issue count for ", f.RepoAt)
929 }
930 source, err := db.GetRepoSource(s.db, f.RepoAt)
931 if errors.Is(err, sql.ErrNoRows) {
932 source = ""
933 } else if err != nil {
934 log.Println("failed to get repo source for ", f.RepoAt, err)
935 }
936
937 var sourceRepo *db.Repo
938 if source != "" {
939 sourceRepo, err = db.GetRepoByAtUri(s.db, source)
940 if err != nil {
941 log.Println("failed to get repo by at uri", err)
942 }
943 }
944
945 var sourceHandle *identity.Identity
946 if sourceRepo != nil {
947 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
948 if err != nil {
949 log.Println("failed to resolve source repo", err)
950 }
951 }
952
953 knot := f.Knot
954 var disableFork bool
955 us, err := NewUnsignedClient(knot, s.config.Dev)
956 if err != nil {
957 log.Printf("failed to create unsigned client for %s: %v", knot, err)
958 } else {
959 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
960 if err != nil {
961 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
962 } else {
963 defer resp.Body.Close()
964 body, err := io.ReadAll(resp.Body)
965 if err != nil {
966 log.Printf("error reading branch response body: %v", err)
967 } else {
968 var branchesResp types.RepoBranchesResponse
969 if err := json.Unmarshal(body, &branchesResp); err != nil {
970 log.Printf("error parsing branch response: %v", err)
971 } else {
972 disableFork = false
973 }
974
975 if len(branchesResp.Branches) == 0 {
976 disableFork = true
977 }
978 }
979 }
980 }
981
982 if knot == "knot1.tangled.sh" {
983 knot = "tangled.sh"
984 }
985
986 repoInfo := pages.RepoInfo{
987 OwnerDid: f.OwnerDid(),
988 OwnerHandle: f.OwnerHandle(),
989 Name: f.RepoName,
990 RepoAt: f.RepoAt,
991 Description: f.Description,
992 IsStarred: isStarred,
993 Knot: knot,
994 Roles: RolesInRepo(s, u, f),
995 Stats: db.RepoStats{
996 StarCount: starCount,
997 IssueCount: issueCount,
998 PullCount: pullCount,
999 },
1000 DisableFork: disableFork,
1001 }
1002
1003 if sourceRepo != nil {
1004 repoInfo.Source = sourceRepo
1005 repoInfo.SourceHandle = sourceHandle.Handle.String()
1006 }
1007
1008 return repoInfo
1009}
1010
1011func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1012 user := s.auth.GetUser(r)
1013 f, err := fullyResolvedRepo(r)
1014 if err != nil {
1015 log.Println("failed to get repo and knot", err)
1016 return
1017 }
1018
1019 issueId := chi.URLParam(r, "issue")
1020 issueIdInt, err := strconv.Atoi(issueId)
1021 if err != nil {
1022 http.Error(w, "bad issue id", http.StatusBadRequest)
1023 log.Println("failed to parse issue id", err)
1024 return
1025 }
1026
1027 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1028 if err != nil {
1029 log.Println("failed to get issue and comments", err)
1030 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1031 return
1032 }
1033
1034 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1035 if err != nil {
1036 log.Println("failed to resolve issue owner", err)
1037 }
1038
1039 identsToResolve := make([]string, len(comments))
1040 for i, comment := range comments {
1041 identsToResolve[i] = comment.OwnerDid
1042 }
1043 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1044 didHandleMap := make(map[string]string)
1045 for _, identity := range resolvedIds {
1046 if !identity.Handle.IsInvalidHandle() {
1047 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1048 } else {
1049 didHandleMap[identity.DID.String()] = identity.DID.String()
1050 }
1051 }
1052
1053 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1054 LoggedInUser: user,
1055 RepoInfo: f.RepoInfo(s, user),
1056 Issue: *issue,
1057 Comments: comments,
1058
1059 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1060 DidHandleMap: didHandleMap,
1061 })
1062
1063}
1064
1065func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1066 user := s.auth.GetUser(r)
1067 f, err := fullyResolvedRepo(r)
1068 if err != nil {
1069 log.Println("failed to get repo and knot", err)
1070 return
1071 }
1072
1073 issueId := chi.URLParam(r, "issue")
1074 issueIdInt, err := strconv.Atoi(issueId)
1075 if err != nil {
1076 http.Error(w, "bad issue id", http.StatusBadRequest)
1077 log.Println("failed to parse issue id", err)
1078 return
1079 }
1080
1081 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1082 if err != nil {
1083 log.Println("failed to get issue", err)
1084 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1085 return
1086 }
1087
1088 collaborators, err := f.Collaborators(r.Context(), s)
1089 if err != nil {
1090 log.Println("failed to fetch repo collaborators: %w", err)
1091 }
1092 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1093 return user.Did == collab.Did
1094 })
1095 isIssueOwner := user.Did == issue.OwnerDid
1096
1097 // TODO: make this more granular
1098 if isIssueOwner || isCollaborator {
1099
1100 closed := tangled.RepoIssueStateClosed
1101
1102 client, _ := s.auth.AuthorizedClient(r)
1103 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1104 Collection: tangled.RepoIssueStateNSID,
1105 Repo: user.Did,
1106 Rkey: s.TID(),
1107 Record: &lexutil.LexiconTypeDecoder{
1108 Val: &tangled.RepoIssueState{
1109 Issue: issue.IssueAt,
1110 State: &closed,
1111 },
1112 },
1113 })
1114
1115 if err != nil {
1116 log.Println("failed to update issue state", err)
1117 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1118 return
1119 }
1120
1121 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1122 if err != nil {
1123 log.Println("failed to close issue", err)
1124 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1125 return
1126 }
1127
1128 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1129 return
1130 } else {
1131 log.Println("user is not permitted to close issue")
1132 http.Error(w, "for biden", http.StatusUnauthorized)
1133 return
1134 }
1135}
1136
1137func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1138 user := s.auth.GetUser(r)
1139 f, err := fullyResolvedRepo(r)
1140 if err != nil {
1141 log.Println("failed to get repo and knot", err)
1142 return
1143 }
1144
1145 issueId := chi.URLParam(r, "issue")
1146 issueIdInt, err := strconv.Atoi(issueId)
1147 if err != nil {
1148 http.Error(w, "bad issue id", http.StatusBadRequest)
1149 log.Println("failed to parse issue id", err)
1150 return
1151 }
1152
1153 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1154 if err != nil {
1155 log.Println("failed to get issue", err)
1156 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1157 return
1158 }
1159
1160 collaborators, err := f.Collaborators(r.Context(), s)
1161 if err != nil {
1162 log.Println("failed to fetch repo collaborators: %w", err)
1163 }
1164 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1165 return user.Did == collab.Did
1166 })
1167 isIssueOwner := user.Did == issue.OwnerDid
1168
1169 if isCollaborator || isIssueOwner {
1170 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1171 if err != nil {
1172 log.Println("failed to reopen issue", err)
1173 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1174 return
1175 }
1176 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1177 return
1178 } else {
1179 log.Println("user is not the owner of the repo")
1180 http.Error(w, "forbidden", http.StatusUnauthorized)
1181 return
1182 }
1183}
1184
1185func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1186 user := s.auth.GetUser(r)
1187 f, err := fullyResolvedRepo(r)
1188 if err != nil {
1189 log.Println("failed to get repo and knot", err)
1190 return
1191 }
1192
1193 issueId := chi.URLParam(r, "issue")
1194 issueIdInt, err := strconv.Atoi(issueId)
1195 if err != nil {
1196 http.Error(w, "bad issue id", http.StatusBadRequest)
1197 log.Println("failed to parse issue id", err)
1198 return
1199 }
1200
1201 switch r.Method {
1202 case http.MethodPost:
1203 body := r.FormValue("body")
1204 if body == "" {
1205 s.pages.Notice(w, "issue", "Body is required")
1206 return
1207 }
1208
1209 commentId := mathrand.IntN(1000000)
1210 rkey := s.TID()
1211
1212 err := db.NewIssueComment(s.db, &db.Comment{
1213 OwnerDid: user.Did,
1214 RepoAt: f.RepoAt,
1215 Issue: issueIdInt,
1216 CommentId: commentId,
1217 Body: body,
1218 Rkey: rkey,
1219 })
1220 if err != nil {
1221 log.Println("failed to create comment", err)
1222 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1223 return
1224 }
1225
1226 createdAt := time.Now().Format(time.RFC3339)
1227 commentIdInt64 := int64(commentId)
1228 ownerDid := user.Did
1229 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1230 if err != nil {
1231 log.Println("failed to get issue at", err)
1232 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1233 return
1234 }
1235
1236 atUri := f.RepoAt.String()
1237 client, _ := s.auth.AuthorizedClient(r)
1238 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1239 Collection: tangled.RepoIssueCommentNSID,
1240 Repo: user.Did,
1241 Rkey: rkey,
1242 Record: &lexutil.LexiconTypeDecoder{
1243 Val: &tangled.RepoIssueComment{
1244 Repo: &atUri,
1245 Issue: issueAt,
1246 CommentId: &commentIdInt64,
1247 Owner: &ownerDid,
1248 Body: &body,
1249 CreatedAt: &createdAt,
1250 },
1251 },
1252 })
1253 if err != nil {
1254 log.Println("failed to create comment", err)
1255 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1256 return
1257 }
1258
1259 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1260 return
1261 }
1262}
1263
1264func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1265 user := s.auth.GetUser(r)
1266 f, err := fullyResolvedRepo(r)
1267 if err != nil {
1268 log.Println("failed to get repo and knot", err)
1269 return
1270 }
1271
1272 issueId := chi.URLParam(r, "issue")
1273 issueIdInt, err := strconv.Atoi(issueId)
1274 if err != nil {
1275 http.Error(w, "bad issue id", http.StatusBadRequest)
1276 log.Println("failed to parse issue id", err)
1277 return
1278 }
1279
1280 commentId := chi.URLParam(r, "comment_id")
1281 commentIdInt, err := strconv.Atoi(commentId)
1282 if err != nil {
1283 http.Error(w, "bad comment id", http.StatusBadRequest)
1284 log.Println("failed to parse issue id", err)
1285 return
1286 }
1287
1288 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1289 if err != nil {
1290 log.Println("failed to get issue", err)
1291 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1292 return
1293 }
1294
1295 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1296 if err != nil {
1297 http.Error(w, "bad comment id", http.StatusBadRequest)
1298 return
1299 }
1300
1301 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1302 if err != nil {
1303 log.Println("failed to resolve did")
1304 return
1305 }
1306
1307 didHandleMap := make(map[string]string)
1308 if !identity.Handle.IsInvalidHandle() {
1309 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1310 } else {
1311 didHandleMap[identity.DID.String()] = identity.DID.String()
1312 }
1313
1314 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1315 LoggedInUser: user,
1316 RepoInfo: f.RepoInfo(s, user),
1317 DidHandleMap: didHandleMap,
1318 Issue: issue,
1319 Comment: comment,
1320 })
1321}
1322
1323func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1324 user := s.auth.GetUser(r)
1325 f, err := fullyResolvedRepo(r)
1326 if err != nil {
1327 log.Println("failed to get repo and knot", err)
1328 return
1329 }
1330
1331 issueId := chi.URLParam(r, "issue")
1332 issueIdInt, err := strconv.Atoi(issueId)
1333 if err != nil {
1334 http.Error(w, "bad issue id", http.StatusBadRequest)
1335 log.Println("failed to parse issue id", err)
1336 return
1337 }
1338
1339 commentId := chi.URLParam(r, "comment_id")
1340 commentIdInt, err := strconv.Atoi(commentId)
1341 if err != nil {
1342 http.Error(w, "bad comment id", http.StatusBadRequest)
1343 log.Println("failed to parse issue id", err)
1344 return
1345 }
1346
1347 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1348 if err != nil {
1349 log.Println("failed to get issue", err)
1350 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1351 return
1352 }
1353
1354 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1355 if err != nil {
1356 http.Error(w, "bad comment id", http.StatusBadRequest)
1357 return
1358 }
1359
1360 if comment.OwnerDid != user.Did {
1361 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1362 return
1363 }
1364
1365 switch r.Method {
1366 case http.MethodGet:
1367 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1368 LoggedInUser: user,
1369 RepoInfo: f.RepoInfo(s, user),
1370 Issue: issue,
1371 Comment: comment,
1372 })
1373 case http.MethodPost:
1374 // extract form value
1375 newBody := r.FormValue("body")
1376 client, _ := s.auth.AuthorizedClient(r)
1377 rkey := comment.Rkey
1378
1379 // optimistic update
1380 edited := time.Now()
1381 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1382 if err != nil {
1383 log.Println("failed to perferom update-description query", err)
1384 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1385 return
1386 }
1387
1388 // rkey is optional, it was introduced later
1389 if comment.Rkey != "" {
1390 // update the record on pds
1391 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1392 if err != nil {
1393 // failed to get record
1394 log.Println(err, rkey)
1395 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1396 return
1397 }
1398 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1399 record, _ := data.UnmarshalJSON(value)
1400
1401 repoAt := record["repo"].(string)
1402 issueAt := record["issue"].(string)
1403 createdAt := record["createdAt"].(string)
1404 commentIdInt64 := int64(commentIdInt)
1405
1406 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1407 Collection: tangled.RepoIssueCommentNSID,
1408 Repo: user.Did,
1409 Rkey: rkey,
1410 SwapRecord: ex.Cid,
1411 Record: &lexutil.LexiconTypeDecoder{
1412 Val: &tangled.RepoIssueComment{
1413 Repo: &repoAt,
1414 Issue: issueAt,
1415 CommentId: &commentIdInt64,
1416 Owner: &comment.OwnerDid,
1417 Body: &newBody,
1418 CreatedAt: &createdAt,
1419 },
1420 },
1421 })
1422 if err != nil {
1423 log.Println(err)
1424 }
1425 }
1426
1427 // optimistic update for htmx
1428 didHandleMap := map[string]string{
1429 user.Did: user.Handle,
1430 }
1431 comment.Body = newBody
1432 comment.Edited = &edited
1433
1434 // return new comment body with htmx
1435 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1436 LoggedInUser: user,
1437 RepoInfo: f.RepoInfo(s, user),
1438 DidHandleMap: didHandleMap,
1439 Issue: issue,
1440 Comment: comment,
1441 })
1442 return
1443
1444 }
1445
1446}
1447
1448func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1449 user := s.auth.GetUser(r)
1450 f, err := fullyResolvedRepo(r)
1451 if err != nil {
1452 log.Println("failed to get repo and knot", err)
1453 return
1454 }
1455
1456 issueId := chi.URLParam(r, "issue")
1457 issueIdInt, err := strconv.Atoi(issueId)
1458 if err != nil {
1459 http.Error(w, "bad issue id", http.StatusBadRequest)
1460 log.Println("failed to parse issue id", err)
1461 return
1462 }
1463
1464 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1465 if err != nil {
1466 log.Println("failed to get issue", err)
1467 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1468 return
1469 }
1470
1471 commentId := chi.URLParam(r, "comment_id")
1472 commentIdInt, err := strconv.Atoi(commentId)
1473 if err != nil {
1474 http.Error(w, "bad comment id", http.StatusBadRequest)
1475 log.Println("failed to parse issue id", err)
1476 return
1477 }
1478
1479 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1480 if err != nil {
1481 http.Error(w, "bad comment id", http.StatusBadRequest)
1482 return
1483 }
1484
1485 if comment.OwnerDid != user.Did {
1486 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1487 return
1488 }
1489
1490 if comment.Deleted != nil {
1491 http.Error(w, "comment already deleted", http.StatusBadRequest)
1492 return
1493 }
1494
1495 // optimistic deletion
1496 deleted := time.Now()
1497 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1498 if err != nil {
1499 log.Println("failed to delete comment")
1500 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1501 return
1502 }
1503
1504 // delete from pds
1505 if comment.Rkey != "" {
1506 client, _ := s.auth.AuthorizedClient(r)
1507 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1508 Collection: tangled.GraphFollowNSID,
1509 Repo: user.Did,
1510 Rkey: comment.Rkey,
1511 })
1512 if err != nil {
1513 log.Println(err)
1514 }
1515 }
1516
1517 // optimistic update for htmx
1518 didHandleMap := map[string]string{
1519 user.Did: user.Handle,
1520 }
1521 comment.Body = ""
1522 comment.Deleted = &deleted
1523
1524 // htmx fragment of comment after deletion
1525 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1526 LoggedInUser: user,
1527 RepoInfo: f.RepoInfo(s, user),
1528 DidHandleMap: didHandleMap,
1529 Issue: issue,
1530 Comment: comment,
1531 })
1532 return
1533}
1534
1535func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1536 params := r.URL.Query()
1537 state := params.Get("state")
1538 isOpen := true
1539 switch state {
1540 case "open":
1541 isOpen = true
1542 case "closed":
1543 isOpen = false
1544 default:
1545 isOpen = true
1546 }
1547
1548 user := s.auth.GetUser(r)
1549 f, err := fullyResolvedRepo(r)
1550 if err != nil {
1551 log.Println("failed to get repo and knot", err)
1552 return
1553 }
1554
1555 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1556 if err != nil {
1557 log.Println("failed to get issues", err)
1558 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1559 return
1560 }
1561
1562 identsToResolve := make([]string, len(issues))
1563 for i, issue := range issues {
1564 identsToResolve[i] = issue.OwnerDid
1565 }
1566 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1567 didHandleMap := make(map[string]string)
1568 for _, identity := range resolvedIds {
1569 if !identity.Handle.IsInvalidHandle() {
1570 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1571 } else {
1572 didHandleMap[identity.DID.String()] = identity.DID.String()
1573 }
1574 }
1575
1576 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1577 LoggedInUser: s.auth.GetUser(r),
1578 RepoInfo: f.RepoInfo(s, user),
1579 Issues: issues,
1580 DidHandleMap: didHandleMap,
1581 FilteringByOpen: isOpen,
1582 })
1583 return
1584}
1585
1586func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1587 user := s.auth.GetUser(r)
1588
1589 f, err := fullyResolvedRepo(r)
1590 if err != nil {
1591 log.Println("failed to get repo and knot", err)
1592 return
1593 }
1594
1595 switch r.Method {
1596 case http.MethodGet:
1597 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1598 LoggedInUser: user,
1599 RepoInfo: f.RepoInfo(s, user),
1600 })
1601 case http.MethodPost:
1602 title := r.FormValue("title")
1603 body := r.FormValue("body")
1604
1605 if title == "" || body == "" {
1606 s.pages.Notice(w, "issues", "Title and body are required")
1607 return
1608 }
1609
1610 tx, err := s.db.BeginTx(r.Context(), nil)
1611 if err != nil {
1612 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1613 return
1614 }
1615
1616 err = db.NewIssue(tx, &db.Issue{
1617 RepoAt: f.RepoAt,
1618 Title: title,
1619 Body: body,
1620 OwnerDid: user.Did,
1621 })
1622 if err != nil {
1623 log.Println("failed to create issue", err)
1624 s.pages.Notice(w, "issues", "Failed to create issue.")
1625 return
1626 }
1627
1628 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1629 if err != nil {
1630 log.Println("failed to get issue id", err)
1631 s.pages.Notice(w, "issues", "Failed to create issue.")
1632 return
1633 }
1634
1635 client, _ := s.auth.AuthorizedClient(r)
1636 atUri := f.RepoAt.String()
1637 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1638 Collection: tangled.RepoIssueNSID,
1639 Repo: user.Did,
1640 Rkey: s.TID(),
1641 Record: &lexutil.LexiconTypeDecoder{
1642 Val: &tangled.RepoIssue{
1643 Repo: atUri,
1644 Title: title,
1645 Body: &body,
1646 Owner: user.Did,
1647 IssueId: int64(issueId),
1648 },
1649 },
1650 })
1651 if err != nil {
1652 log.Println("failed to create issue", err)
1653 s.pages.Notice(w, "issues", "Failed to create issue.")
1654 return
1655 }
1656
1657 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1658 if err != nil {
1659 log.Println("failed to set issue at", err)
1660 s.pages.Notice(w, "issues", "Failed to create issue.")
1661 return
1662 }
1663
1664 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1665 return
1666 }
1667}
1668
1669func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1670 user := s.auth.GetUser(r)
1671 f, err := fullyResolvedRepo(r)
1672 if err != nil {
1673 log.Printf("failed to resolve source repo: %v", err)
1674 return
1675 }
1676
1677 switch r.Method {
1678 case http.MethodGet:
1679 user := s.auth.GetUser(r)
1680 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1681 if err != nil {
1682 s.pages.Notice(w, "repo", "Invalid user account.")
1683 return
1684 }
1685
1686 s.pages.ForkRepo(w, pages.ForkRepoParams{
1687 LoggedInUser: user,
1688 Knots: knots,
1689 RepoInfo: f.RepoInfo(s, user),
1690 })
1691
1692 case http.MethodPost:
1693
1694 knot := r.FormValue("knot")
1695 if knot == "" {
1696 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1697 return
1698 }
1699
1700 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1701 if err != nil || !ok {
1702 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1703 return
1704 }
1705
1706 forkName := fmt.Sprintf("%s", f.RepoName)
1707
1708 // this check is *only* to see if the forked repo name already exists
1709 // in the user's account.
1710 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1711 if err != nil {
1712 if errors.Is(err, sql.ErrNoRows) {
1713 // no existing repo with this name found, we can use the name as is
1714 } else {
1715 log.Println("error fetching existing repo from db", err)
1716 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1717 return
1718 }
1719 } else if existingRepo != nil {
1720 // repo with this name already exists, append random string
1721 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1722 }
1723 secret, err := db.GetRegistrationKey(s.db, knot)
1724 if err != nil {
1725 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1726 return
1727 }
1728
1729 client, err := NewSignedClient(knot, secret, s.config.Dev)
1730 if err != nil {
1731 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1732 return
1733 }
1734
1735 var uri string
1736 if s.config.Dev {
1737 uri = "http"
1738 } else {
1739 uri = "https"
1740 }
1741 sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1742 sourceAt := f.RepoAt.String()
1743
1744 rkey := s.TID()
1745 repo := &db.Repo{
1746 Did: user.Did,
1747 Name: forkName,
1748 Knot: knot,
1749 Rkey: rkey,
1750 Source: sourceAt,
1751 }
1752
1753 tx, err := s.db.BeginTx(r.Context(), nil)
1754 if err != nil {
1755 log.Println(err)
1756 s.pages.Notice(w, "repo", "Failed to save repository information.")
1757 return
1758 }
1759 defer func() {
1760 tx.Rollback()
1761 err = s.enforcer.E.LoadPolicy()
1762 if err != nil {
1763 log.Println("failed to rollback policies")
1764 }
1765 }()
1766
1767 resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
1768 if err != nil {
1769 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1770 return
1771 }
1772
1773 switch resp.StatusCode {
1774 case http.StatusConflict:
1775 s.pages.Notice(w, "repo", "A repository with that name already exists.")
1776 return
1777 case http.StatusInternalServerError:
1778 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1779 case http.StatusNoContent:
1780 // continue
1781 }
1782
1783 xrpcClient, _ := s.auth.AuthorizedClient(r)
1784
1785 addedAt := time.Now().Format(time.RFC3339)
1786 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
1787 Collection: tangled.RepoNSID,
1788 Repo: user.Did,
1789 Rkey: rkey,
1790 Record: &lexutil.LexiconTypeDecoder{
1791 Val: &tangled.Repo{
1792 Knot: repo.Knot,
1793 Name: repo.Name,
1794 AddedAt: &addedAt,
1795 Owner: user.Did,
1796 Source: &sourceAt,
1797 }},
1798 })
1799 if err != nil {
1800 log.Printf("failed to create record: %s", err)
1801 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1802 return
1803 }
1804 log.Println("created repo record: ", atresp.Uri)
1805
1806 repo.AtUri = atresp.Uri
1807 err = db.AddRepo(tx, repo)
1808 if err != nil {
1809 log.Println(err)
1810 s.pages.Notice(w, "repo", "Failed to save repository information.")
1811 return
1812 }
1813
1814 // acls
1815 p, _ := securejoin.SecureJoin(user.Did, forkName)
1816 err = s.enforcer.AddRepo(user.Did, knot, p)
1817 if err != nil {
1818 log.Println(err)
1819 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1820 return
1821 }
1822
1823 err = tx.Commit()
1824 if err != nil {
1825 log.Println("failed to commit changes", err)
1826 http.Error(w, err.Error(), http.StatusInternalServerError)
1827 return
1828 }
1829
1830 err = s.enforcer.E.SavePolicy()
1831 if err != nil {
1832 log.Println("failed to update ACLs", err)
1833 http.Error(w, err.Error(), http.StatusInternalServerError)
1834 return
1835 }
1836
1837 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1838 return
1839 }
1840}