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