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