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