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