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