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