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