1package state
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "math/rand/v2"
10 "net/http"
11 "path"
12 "slices"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/bluesky-social/indigo/atproto/identity"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 securejoin "github.com/cyphar/filepath-securejoin"
20 "github.com/go-chi/chi/v5"
21 "github.com/sotangled/tangled/api/tangled"
22 "github.com/sotangled/tangled/appview/auth"
23 "github.com/sotangled/tangled/appview/db"
24 "github.com/sotangled/tangled/appview/pages"
25 "github.com/sotangled/tangled/types"
26
27 comatproto "github.com/bluesky-social/indigo/api/atproto"
28 lexutil "github.com/bluesky-social/indigo/lex/util"
29)
30
31func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
32 ref := chi.URLParam(r, "ref")
33 f, err := fullyResolvedRepo(r)
34 if err != nil {
35 log.Println("failed to fully resolve repo", err)
36 return
37 }
38
39 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
40 if err != nil {
41 log.Printf("failed to create unsigned client for %s", f.Knot)
42 s.pages.Error503(w)
43 return
44 }
45
46 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
47 if err != nil {
48 s.pages.Error503(w)
49 log.Println("failed to reach knotserver", err)
50 return
51 }
52 defer resp.Body.Close()
53
54 body, err := io.ReadAll(resp.Body)
55 if err != nil {
56 log.Printf("Error reading response body: %v", err)
57 return
58 }
59
60 var result types.RepoIndexResponse
61 err = json.Unmarshal(body, &result)
62 if err != nil {
63 log.Printf("Error unmarshalling response body: %v", err)
64 return
65 }
66
67 tagMap := make(map[string][]string)
68 for _, tag := range result.Tags {
69 hash := tag.Hash
70 tagMap[hash] = append(tagMap[hash], tag.Name)
71 }
72
73 for _, branch := range result.Branches {
74 hash := branch.Hash
75 tagMap[hash] = append(tagMap[hash], branch.Name)
76 }
77
78 user := s.auth.GetUser(r)
79 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
80 LoggedInUser: user,
81 RepoInfo: f.RepoInfo(s, user),
82 TagMap: tagMap,
83 RepoIndexResponse: result,
84 })
85
86 return
87}
88
89func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
90 f, err := fullyResolvedRepo(r)
91 if err != nil {
92 log.Println("failed to fully resolve repo", err)
93 return
94 }
95
96 page := 1
97 if r.URL.Query().Get("page") != "" {
98 page, err = strconv.Atoi(r.URL.Query().Get("page"))
99 if err != nil {
100 page = 1
101 }
102 }
103
104 ref := chi.URLParam(r, "ref")
105
106 protocol := "http"
107 if !s.config.Dev {
108 protocol = "https"
109 }
110
111 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))
112 if err != nil {
113 log.Println("failed to reach knotserver", err)
114 return
115 }
116
117 body, err := io.ReadAll(resp.Body)
118 if err != nil {
119 log.Printf("error reading response body: %v", err)
120 return
121 }
122
123 var repolog types.RepoLogResponse
124 err = json.Unmarshal(body, &repolog)
125 if err != nil {
126 log.Println("failed to parse json response", err)
127 return
128 }
129
130 user := s.auth.GetUser(r)
131 s.pages.RepoLog(w, pages.RepoLogParams{
132 LoggedInUser: user,
133 RepoInfo: f.RepoInfo(s, user),
134 RepoLogResponse: repolog,
135 })
136 return
137}
138
139func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
140 f, err := fullyResolvedRepo(r)
141 if err != nil {
142 log.Println("failed to get repo and knot", err)
143 w.WriteHeader(http.StatusBadRequest)
144 return
145 }
146
147 user := s.auth.GetUser(r)
148 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
149 RepoInfo: f.RepoInfo(s, user),
150 })
151 return
152}
153
154func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
155 f, err := fullyResolvedRepo(r)
156 if err != nil {
157 log.Println("failed to get repo and knot", err)
158 w.WriteHeader(http.StatusBadRequest)
159 return
160 }
161
162 repoAt := f.RepoAt
163 rkey := repoAt.RecordKey().String()
164 if rkey == "" {
165 log.Println("invalid aturi for repo", err)
166 w.WriteHeader(http.StatusInternalServerError)
167 return
168 }
169
170 user := s.auth.GetUser(r)
171
172 switch r.Method {
173 case http.MethodGet:
174 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
175 RepoInfo: f.RepoInfo(s, user),
176 })
177 return
178 case http.MethodPut:
179 user := s.auth.GetUser(r)
180 newDescription := r.FormValue("description")
181 client, _ := s.auth.AuthorizedClient(r)
182
183 // optimistic update
184 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
185 if err != nil {
186 log.Println("failed to perferom update-description query", err)
187 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
188 return
189 }
190
191 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
192 //
193 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
194 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
195 if err != nil {
196 // failed to get record
197 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
198 return
199 }
200 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
201 Collection: tangled.RepoNSID,
202 Repo: user.Did,
203 Rkey: rkey,
204 SwapRecord: ex.Cid,
205 Record: &lexutil.LexiconTypeDecoder{
206 Val: &tangled.Repo{
207 Knot: f.Knot,
208 Name: f.RepoName,
209 Owner: user.Did,
210 AddedAt: &f.AddedAt,
211 Description: &newDescription,
212 },
213 },
214 })
215
216 if err != nil {
217 log.Println("failed to perferom update-description query", err)
218 // failed to get record
219 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
220 return
221 }
222
223 newRepoInfo := f.RepoInfo(s, user)
224 newRepoInfo.Description = newDescription
225
226 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
227 RepoInfo: newRepoInfo,
228 })
229 return
230 }
231}
232
233// MergeCheck gets called async, every time the patch diff is updated in a pull.
234func (s *State) MergeCheck(w http.ResponseWriter, r *http.Request) {
235 user := s.auth.GetUser(r)
236 f, err := fullyResolvedRepo(r)
237 if err != nil {
238 log.Println("failed to get repo and knot", err)
239 s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.")
240 return
241 }
242
243 patch := r.FormValue("patch")
244 targetBranch := r.FormValue("targetBranch")
245
246 if patch == "" || targetBranch == "" {
247 s.pages.Notice(w, "pull", "Patch and target branch are required.")
248 return
249 }
250
251 secret, err := db.GetRegistrationKey(s.db, f.Knot)
252 if err != nil {
253 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
254 s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.")
255 return
256 }
257
258 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
259 if err != nil {
260 log.Printf("failed to create signed client for %s", f.Knot)
261 s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.")
262 return
263 }
264
265 resp, err := ksClient.MergeCheck([]byte(patch), user.Did, f.RepoName, targetBranch)
266 if err != nil {
267 log.Println("failed to check mergeability", err)
268 s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.")
269 return
270 }
271
272 respBody, err := io.ReadAll(resp.Body)
273 if err != nil {
274 log.Println("failed to read knotserver response body")
275 s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.")
276 return
277 }
278
279 var mergeCheckResponse types.MergeCheckResponse
280 err = json.Unmarshal(respBody, &mergeCheckResponse)
281 if err != nil {
282 log.Println("failed to unmarshal merge check response", err)
283 s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.")
284 return
285 }
286
287 // TODO: this has to return a html fragment
288 w.Header().Set("Content-Type", "application/json")
289 json.NewEncoder(w).Encode(mergeCheckResponse)
290}
291
292func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
293 user := s.auth.GetUser(r)
294 f, err := fullyResolvedRepo(r)
295 if err != nil {
296 log.Println("failed to get repo and knot", err)
297 return
298 }
299
300 switch r.Method {
301 case http.MethodGet:
302 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
303 if err != nil {
304 log.Printf("failed to create unsigned client for %s", f.Knot)
305 s.pages.Error503(w)
306 return
307 }
308
309 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
310 if err != nil {
311 log.Println("failed to reach knotserver", err)
312 return
313 }
314
315 body, err := io.ReadAll(resp.Body)
316 if err != nil {
317 log.Printf("Error reading response body: %v", err)
318 return
319 }
320
321 var result types.RepoBranchesResponse
322 err = json.Unmarshal(body, &result)
323 if err != nil {
324 log.Println("failed to parse response:", err)
325 return
326 }
327
328 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
329 LoggedInUser: user,
330 RepoInfo: f.RepoInfo(s, user),
331 Branches: result.Branches,
332 })
333 case http.MethodPost:
334 title := r.FormValue("title")
335 body := r.FormValue("body")
336 targetBranch := r.FormValue("targetBranch")
337 patch := r.FormValue("patch")
338
339 if title == "" || body == "" || patch == "" {
340 s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
341 return
342 }
343
344 tx, err := s.db.BeginTx(r.Context(), nil)
345 if err != nil {
346 log.Println("failed to start tx")
347 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
348 return
349 }
350
351 defer func() {
352 tx.Rollback()
353 err = s.enforcer.E.LoadPolicy()
354 if err != nil {
355 log.Println("failed to rollback policies")
356 }
357 }()
358
359 err = db.NewPull(tx, &db.Pull{
360 Title: title,
361 Body: body,
362 TargetBranch: targetBranch,
363 Patch: patch,
364 OwnerDid: user.Did,
365 RepoAt: f.RepoAt,
366 })
367 if err != nil {
368 log.Println("failed to create pull request", err)
369 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
370 return
371 }
372 client, _ := s.auth.AuthorizedClient(r)
373 pullId, err := db.NextPullId(s.db, f.RepoAt)
374 if err != nil {
375 log.Println("failed to get pull id", err)
376 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
377 return
378 }
379
380 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
381 Collection: tangled.RepoPullNSID,
382 Repo: user.Did,
383 Rkey: s.TID(),
384 Record: &lexutil.LexiconTypeDecoder{
385 Val: &tangled.RepoPull{
386 Title: title,
387 PullId: int64(pullId),
388 TargetRepo: string(f.RepoAt),
389 TargetBranch: targetBranch,
390 Patch: patch,
391 },
392 },
393 })
394
395 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
396 if err != nil {
397 log.Println("failed to get pull id", err)
398 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
399 return
400 }
401
402 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
403 return
404 }
405}
406
407func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
408 user := s.auth.GetUser(r)
409 f, err := fullyResolvedRepo(r)
410 if err != nil {
411 log.Println("failed to get repo and knot", err)
412 return
413 }
414
415 prId := chi.URLParam(r, "pull")
416 prIdInt, err := strconv.Atoi(prId)
417 if err != nil {
418 http.Error(w, "bad pr id", http.StatusBadRequest)
419 log.Println("failed to parse pr id", err)
420 return
421 }
422
423 pr, comments, err := db.GetPullWithComments(s.db, f.RepoAt, prIdInt)
424 if err != nil {
425 log.Println("failed to get pr and comments", err)
426 s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.")
427 return
428 }
429
430 pullOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), pr.OwnerDid)
431 if err != nil {
432 log.Println("failed to resolve pull owner", err)
433 }
434
435 identsToResolve := make([]string, len(comments))
436 for i, comment := range comments {
437 identsToResolve[i] = comment.OwnerDid
438 }
439 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
440 didHandleMap := make(map[string]string)
441 for _, identity := range resolvedIds {
442 if !identity.Handle.IsInvalidHandle() {
443 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
444 } else {
445 didHandleMap[identity.DID.String()] = identity.DID.String()
446 }
447 }
448
449 secret, err := db.GetRegistrationKey(s.db, f.Knot)
450 if err != nil {
451 log.Printf("failed to get registration key for %s", f.Knot)
452 s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.")
453 return
454 }
455
456 var mergeCheckResponse types.MergeCheckResponse
457 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
458 if err == nil {
459 resp, err := ksClient.MergeCheck([]byte(pr.Patch), pr.OwnerDid, f.RepoName, pr.TargetBranch)
460 if err != nil {
461 log.Println("failed to check for mergeability:", err)
462 } else {
463 respBody, err := io.ReadAll(resp.Body)
464 if err != nil {
465 log.Println("failed to read merge check response body")
466 } else {
467 err = json.Unmarshal(respBody, &mergeCheckResponse)
468 if err != nil {
469 log.Println("failed to unmarshal merge check response", err)
470 }
471 }
472 }
473 } else {
474 log.Printf("failed to setup signed client for %s; ignoring...", f.Knot)
475 }
476
477 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
478 LoggedInUser: user,
479 RepoInfo: f.RepoInfo(s, user),
480 Pull: *pr,
481 Comments: comments,
482 PullOwnerHandle: pullOwnerIdent.Handle.String(),
483 DidHandleMap: didHandleMap,
484 MergeCheck: mergeCheckResponse,
485 })
486}
487
488func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
489 f, err := fullyResolvedRepo(r)
490 if err != nil {
491 log.Println("failed to fully resolve repo", err)
492 return
493 }
494 ref := chi.URLParam(r, "ref")
495 protocol := "http"
496 if !s.config.Dev {
497 protocol = "https"
498 }
499 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
500 if err != nil {
501 log.Println("failed to reach knotserver", err)
502 return
503 }
504
505 body, err := io.ReadAll(resp.Body)
506 if err != nil {
507 log.Printf("Error reading response body: %v", err)
508 return
509 }
510
511 var result types.RepoCommitResponse
512 err = json.Unmarshal(body, &result)
513 if err != nil {
514 log.Println("failed to parse response:", err)
515 return
516 }
517
518 user := s.auth.GetUser(r)
519 s.pages.RepoCommit(w, pages.RepoCommitParams{
520 LoggedInUser: user,
521 RepoInfo: f.RepoInfo(s, user),
522 RepoCommitResponse: result,
523 })
524 return
525}
526
527func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
528 f, err := fullyResolvedRepo(r)
529 if err != nil {
530 log.Println("failed to fully resolve repo", err)
531 return
532 }
533
534 ref := chi.URLParam(r, "ref")
535 treePath := chi.URLParam(r, "*")
536 protocol := "http"
537 if !s.config.Dev {
538 protocol = "https"
539 }
540 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
541 if err != nil {
542 log.Println("failed to reach knotserver", err)
543 return
544 }
545
546 body, err := io.ReadAll(resp.Body)
547 if err != nil {
548 log.Printf("Error reading response body: %v", err)
549 return
550 }
551
552 var result types.RepoTreeResponse
553 err = json.Unmarshal(body, &result)
554 if err != nil {
555 log.Println("failed to parse response:", err)
556 return
557 }
558
559 user := s.auth.GetUser(r)
560
561 var breadcrumbs [][]string
562 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
563 if treePath != "" {
564 for idx, elem := range strings.Split(treePath, "/") {
565 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
566 }
567 }
568
569 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
570 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
571
572 s.pages.RepoTree(w, pages.RepoTreeParams{
573 LoggedInUser: user,
574 BreadCrumbs: breadcrumbs,
575 BaseTreeLink: baseTreeLink,
576 BaseBlobLink: baseBlobLink,
577 RepoInfo: f.RepoInfo(s, user),
578 RepoTreeResponse: result,
579 })
580 return
581}
582
583func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
584 f, err := fullyResolvedRepo(r)
585 if err != nil {
586 log.Println("failed to get repo and knot", err)
587 return
588 }
589
590 protocol := "http"
591 if !s.config.Dev {
592 protocol = "https"
593 }
594
595 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
596 if err != nil {
597 log.Println("failed to reach knotserver", err)
598 return
599 }
600
601 body, err := io.ReadAll(resp.Body)
602 if err != nil {
603 log.Printf("Error reading response body: %v", err)
604 return
605 }
606
607 var result types.RepoTagsResponse
608 err = json.Unmarshal(body, &result)
609 if err != nil {
610 log.Println("failed to parse response:", err)
611 return
612 }
613
614 user := s.auth.GetUser(r)
615 s.pages.RepoTags(w, pages.RepoTagsParams{
616 LoggedInUser: user,
617 RepoInfo: f.RepoInfo(s, user),
618 RepoTagsResponse: result,
619 })
620 return
621}
622
623func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
624 f, err := fullyResolvedRepo(r)
625 if err != nil {
626 log.Println("failed to get repo and knot", err)
627 return
628 }
629
630 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
631 if err != nil {
632 log.Println("failed to create unsigned client", err)
633 return
634 }
635
636 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
637 if err != nil {
638 log.Println("failed to reach knotserver", err)
639 return
640 }
641
642 body, err := io.ReadAll(resp.Body)
643 if err != nil {
644 log.Printf("Error reading response body: %v", err)
645 return
646 }
647
648 var result types.RepoBranchesResponse
649 err = json.Unmarshal(body, &result)
650 if err != nil {
651 log.Println("failed to parse response:", err)
652 return
653 }
654
655 user := s.auth.GetUser(r)
656 s.pages.RepoBranches(w, pages.RepoBranchesParams{
657 LoggedInUser: user,
658 RepoInfo: f.RepoInfo(s, user),
659 RepoBranchesResponse: result,
660 })
661 return
662}
663
664func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
665 f, err := fullyResolvedRepo(r)
666 if err != nil {
667 log.Println("failed to get repo and knot", err)
668 return
669 }
670
671 ref := chi.URLParam(r, "ref")
672 filePath := chi.URLParam(r, "*")
673 protocol := "http"
674 if !s.config.Dev {
675 protocol = "https"
676 }
677 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
678 if err != nil {
679 log.Println("failed to reach knotserver", err)
680 return
681 }
682
683 body, err := io.ReadAll(resp.Body)
684 if err != nil {
685 log.Printf("Error reading response body: %v", err)
686 return
687 }
688
689 var result types.RepoBlobResponse
690 err = json.Unmarshal(body, &result)
691 if err != nil {
692 log.Println("failed to parse response:", err)
693 return
694 }
695
696 var breadcrumbs [][]string
697 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
698 if filePath != "" {
699 for idx, elem := range strings.Split(filePath, "/") {
700 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
701 }
702 }
703
704 user := s.auth.GetUser(r)
705 s.pages.RepoBlob(w, pages.RepoBlobParams{
706 LoggedInUser: user,
707 RepoInfo: f.RepoInfo(s, user),
708 RepoBlobResponse: result,
709 BreadCrumbs: breadcrumbs,
710 })
711 return
712}
713
714func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
715 f, err := fullyResolvedRepo(r)
716 if err != nil {
717 log.Println("failed to get repo and knot", err)
718 return
719 }
720
721 collaborator := r.FormValue("collaborator")
722 if collaborator == "" {
723 http.Error(w, "malformed form", http.StatusBadRequest)
724 return
725 }
726
727 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
728 if err != nil {
729 w.Write([]byte("failed to resolve collaborator did to a handle"))
730 return
731 }
732 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
733
734 // TODO: create an atproto record for this
735
736 secret, err := db.GetRegistrationKey(s.db, f.Knot)
737 if err != nil {
738 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
739 return
740 }
741
742 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
743 if err != nil {
744 log.Println("failed to create client to ", f.Knot)
745 return
746 }
747
748 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
749 if err != nil {
750 log.Printf("failed to make request to %s: %s", f.Knot, err)
751 return
752 }
753
754 if ksResp.StatusCode != http.StatusNoContent {
755 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
756 return
757 }
758
759 tx, err := s.db.BeginTx(r.Context(), nil)
760 if err != nil {
761 log.Println("failed to start tx")
762 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
763 return
764 }
765 defer func() {
766 tx.Rollback()
767 err = s.enforcer.E.LoadPolicy()
768 if err != nil {
769 log.Println("failed to rollback policies")
770 }
771 }()
772
773 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
774 if err != nil {
775 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
776 return
777 }
778
779 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
780 if err != nil {
781 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
782 return
783 }
784
785 err = tx.Commit()
786 if err != nil {
787 log.Println("failed to commit changes", err)
788 http.Error(w, err.Error(), http.StatusInternalServerError)
789 return
790 }
791
792 err = s.enforcer.E.SavePolicy()
793 if err != nil {
794 log.Println("failed to update ACLs", err)
795 http.Error(w, err.Error(), http.StatusInternalServerError)
796 return
797 }
798
799 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
800
801}
802
803func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
804 f, err := fullyResolvedRepo(r)
805 if err != nil {
806 log.Println("failed to get repo and knot", err)
807 return
808 }
809
810 switch r.Method {
811 case http.MethodGet:
812 // for now, this is just pubkeys
813 user := s.auth.GetUser(r)
814 repoCollaborators, err := f.Collaborators(r.Context(), s)
815 if err != nil {
816 log.Println("failed to get collaborators", err)
817 }
818
819 isCollaboratorInviteAllowed := false
820 if user != nil {
821 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
822 if err == nil && ok {
823 isCollaboratorInviteAllowed = true
824 }
825 }
826
827 s.pages.RepoSettings(w, pages.RepoSettingsParams{
828 LoggedInUser: user,
829 RepoInfo: f.RepoInfo(s, user),
830 Collaborators: repoCollaborators,
831 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
832 })
833 }
834}
835
836type FullyResolvedRepo struct {
837 Knot string
838 OwnerId identity.Identity
839 RepoName string
840 RepoAt syntax.ATURI
841 Description string
842 AddedAt string
843}
844
845func (f *FullyResolvedRepo) OwnerDid() string {
846 return f.OwnerId.DID.String()
847}
848
849func (f *FullyResolvedRepo) OwnerHandle() string {
850 return f.OwnerId.Handle.String()
851}
852
853func (f *FullyResolvedRepo) OwnerSlashRepo() string {
854 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
855 return p
856}
857
858func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
859 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
860 if err != nil {
861 return nil, err
862 }
863
864 var collaborators []pages.Collaborator
865 for _, item := range repoCollaborators {
866 // currently only two roles: owner and member
867 var role string
868 if item[3] == "repo:owner" {
869 role = "owner"
870 } else if item[3] == "repo:collaborator" {
871 role = "collaborator"
872 } else {
873 continue
874 }
875
876 did := item[0]
877
878 c := pages.Collaborator{
879 Did: did,
880 Handle: "",
881 Role: role,
882 }
883 collaborators = append(collaborators, c)
884 }
885
886 // populate all collborators with handles
887 identsToResolve := make([]string, len(collaborators))
888 for i, collab := range collaborators {
889 identsToResolve[i] = collab.Did
890 }
891
892 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
893 for i, resolved := range resolvedIdents {
894 if resolved != nil {
895 collaborators[i].Handle = resolved.Handle.String()
896 }
897 }
898
899 return collaborators, nil
900}
901
902func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
903 isStarred := false
904 if u != nil {
905 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
906 }
907
908 starCount, err := db.GetStarCount(s.db, f.RepoAt)
909 if err != nil {
910 log.Println("failed to get star count for ", f.RepoAt)
911 }
912 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
913 if err != nil {
914 log.Println("failed to get issue count for ", f.RepoAt)
915 }
916
917 knot := f.Knot
918 if knot == "knot1.tangled.sh" {
919 knot = "tangled.sh"
920 }
921
922 return pages.RepoInfo{
923 OwnerDid: f.OwnerDid(),
924 OwnerHandle: f.OwnerHandle(),
925 Name: f.RepoName,
926 RepoAt: f.RepoAt,
927 Description: f.Description,
928 IsStarred: isStarred,
929 Knot: knot,
930 Roles: rolesInRepo(s, u, f),
931 Stats: db.RepoStats{
932 StarCount: starCount,
933 IssueCount: issueCount,
934 },
935 }
936}
937
938func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
939 user := s.auth.GetUser(r)
940 f, err := fullyResolvedRepo(r)
941 if err != nil {
942 log.Println("failed to get repo and knot", err)
943 return
944 }
945
946 issueId := chi.URLParam(r, "issue")
947 issueIdInt, err := strconv.Atoi(issueId)
948 if err != nil {
949 http.Error(w, "bad issue id", http.StatusBadRequest)
950 log.Println("failed to parse issue id", err)
951 return
952 }
953
954 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
955 if err != nil {
956 log.Println("failed to get issue and comments", err)
957 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
958 return
959 }
960
961 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
962 if err != nil {
963 log.Println("failed to resolve issue owner", err)
964 }
965
966 identsToResolve := make([]string, len(comments))
967 for i, comment := range comments {
968 identsToResolve[i] = comment.OwnerDid
969 }
970 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
971 didHandleMap := make(map[string]string)
972 for _, identity := range resolvedIds {
973 if !identity.Handle.IsInvalidHandle() {
974 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
975 } else {
976 didHandleMap[identity.DID.String()] = identity.DID.String()
977 }
978 }
979
980 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
981 LoggedInUser: user,
982 RepoInfo: f.RepoInfo(s, user),
983 Issue: *issue,
984 Comments: comments,
985
986 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
987 DidHandleMap: didHandleMap,
988 })
989
990}
991
992func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
993 user := s.auth.GetUser(r)
994 f, err := fullyResolvedRepo(r)
995 if err != nil {
996 log.Println("failed to get repo and knot", err)
997 return
998 }
999
1000 issueId := chi.URLParam(r, "issue")
1001 issueIdInt, err := strconv.Atoi(issueId)
1002 if err != nil {
1003 http.Error(w, "bad issue id", http.StatusBadRequest)
1004 log.Println("failed to parse issue id", err)
1005 return
1006 }
1007
1008 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1009 if err != nil {
1010 log.Println("failed to get issue", err)
1011 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1012 return
1013 }
1014
1015 collaborators, err := f.Collaborators(r.Context(), s)
1016 if err != nil {
1017 log.Println("failed to fetch repo collaborators: %w", err)
1018 }
1019 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1020 return user.Did == collab.Did
1021 })
1022 isIssueOwner := user.Did == issue.OwnerDid
1023
1024 // TODO: make this more granular
1025 if isIssueOwner || isCollaborator {
1026
1027 closed := tangled.RepoIssueStateClosed
1028
1029 client, _ := s.auth.AuthorizedClient(r)
1030 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1031 Collection: tangled.RepoIssueStateNSID,
1032 Repo: user.Did,
1033 Rkey: s.TID(),
1034 Record: &lexutil.LexiconTypeDecoder{
1035 Val: &tangled.RepoIssueState{
1036 Issue: issue.IssueAt,
1037 State: &closed,
1038 },
1039 },
1040 })
1041
1042 if err != nil {
1043 log.Println("failed to update issue state", err)
1044 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1045 return
1046 }
1047
1048 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1049 if err != nil {
1050 log.Println("failed to close issue", err)
1051 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1052 return
1053 }
1054
1055 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1056 return
1057 } else {
1058 log.Println("user is not permitted to close issue")
1059 http.Error(w, "for biden", http.StatusUnauthorized)
1060 return
1061 }
1062}
1063
1064func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1065 user := s.auth.GetUser(r)
1066 f, err := fullyResolvedRepo(r)
1067 if err != nil {
1068 log.Println("failed to get repo and knot", err)
1069 return
1070 }
1071
1072 issueId := chi.URLParam(r, "issue")
1073 issueIdInt, err := strconv.Atoi(issueId)
1074 if err != nil {
1075 http.Error(w, "bad issue id", http.StatusBadRequest)
1076 log.Println("failed to parse issue id", err)
1077 return
1078 }
1079
1080 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1081 if err != nil {
1082 log.Println("failed to get issue", err)
1083 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1084 return
1085 }
1086
1087 collaborators, err := f.Collaborators(r.Context(), s)
1088 if err != nil {
1089 log.Println("failed to fetch repo collaborators: %w", err)
1090 }
1091 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1092 return user.Did == collab.Did
1093 })
1094 isIssueOwner := user.Did == issue.OwnerDid
1095
1096 if isCollaborator || isIssueOwner {
1097 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1098 if err != nil {
1099 log.Println("failed to reopen issue", err)
1100 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1101 return
1102 }
1103 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1104 return
1105 } else {
1106 log.Println("user is not the owner of the repo")
1107 http.Error(w, "forbidden", http.StatusUnauthorized)
1108 return
1109 }
1110}
1111
1112func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1113 user := s.auth.GetUser(r)
1114 f, err := fullyResolvedRepo(r)
1115 if err != nil {
1116 log.Println("failed to get repo and knot", err)
1117 return
1118 }
1119
1120 issueId := chi.URLParam(r, "issue")
1121 issueIdInt, err := strconv.Atoi(issueId)
1122 if err != nil {
1123 http.Error(w, "bad issue id", http.StatusBadRequest)
1124 log.Println("failed to parse issue id", err)
1125 return
1126 }
1127
1128 switch r.Method {
1129 case http.MethodPost:
1130 body := r.FormValue("body")
1131 if body == "" {
1132 s.pages.Notice(w, "issue", "Body is required")
1133 return
1134 }
1135
1136 commentId := rand.IntN(1000000)
1137
1138 err := db.NewComment(s.db, &db.Comment{
1139 OwnerDid: user.Did,
1140 RepoAt: f.RepoAt,
1141 Issue: issueIdInt,
1142 CommentId: commentId,
1143 Body: body,
1144 })
1145 if err != nil {
1146 log.Println("failed to create comment", err)
1147 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1148 return
1149 }
1150
1151 createdAt := time.Now().Format(time.RFC3339)
1152 commentIdInt64 := int64(commentId)
1153 ownerDid := user.Did
1154 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1155 if err != nil {
1156 log.Println("failed to get issue at", err)
1157 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1158 return
1159 }
1160
1161 atUri := f.RepoAt.String()
1162 client, _ := s.auth.AuthorizedClient(r)
1163 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1164 Collection: tangled.RepoIssueCommentNSID,
1165 Repo: user.Did,
1166 Rkey: s.TID(),
1167 Record: &lexutil.LexiconTypeDecoder{
1168 Val: &tangled.RepoIssueComment{
1169 Repo: &atUri,
1170 Issue: issueAt,
1171 CommentId: &commentIdInt64,
1172 Owner: &ownerDid,
1173 Body: &body,
1174 CreatedAt: &createdAt,
1175 },
1176 },
1177 })
1178 if err != nil {
1179 log.Println("failed to create comment", err)
1180 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1181 return
1182 }
1183
1184 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1185 return
1186 }
1187}
1188
1189func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1190 params := r.URL.Query()
1191 state := params.Get("state")
1192 isOpen := true
1193 switch state {
1194 case "open":
1195 isOpen = true
1196 case "closed":
1197 isOpen = false
1198 default:
1199 isOpen = true
1200 }
1201
1202 user := s.auth.GetUser(r)
1203 f, err := fullyResolvedRepo(r)
1204 if err != nil {
1205 log.Println("failed to get repo and knot", err)
1206 return
1207 }
1208
1209 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1210 if err != nil {
1211 log.Println("failed to get issues", err)
1212 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1213 return
1214 }
1215
1216 identsToResolve := make([]string, len(issues))
1217 for i, issue := range issues {
1218 identsToResolve[i] = issue.OwnerDid
1219 }
1220 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1221 didHandleMap := make(map[string]string)
1222 for _, identity := range resolvedIds {
1223 if !identity.Handle.IsInvalidHandle() {
1224 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1225 } else {
1226 didHandleMap[identity.DID.String()] = identity.DID.String()
1227 }
1228 }
1229
1230 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1231 LoggedInUser: s.auth.GetUser(r),
1232 RepoInfo: f.RepoInfo(s, user),
1233 Issues: issues,
1234 DidHandleMap: didHandleMap,
1235 FilteringByOpen: isOpen,
1236 })
1237 return
1238}
1239
1240func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1241 user := s.auth.GetUser(r)
1242
1243 f, err := fullyResolvedRepo(r)
1244 if err != nil {
1245 log.Println("failed to get repo and knot", err)
1246 return
1247 }
1248
1249 switch r.Method {
1250 case http.MethodGet:
1251 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1252 LoggedInUser: user,
1253 RepoInfo: f.RepoInfo(s, user),
1254 })
1255 case http.MethodPost:
1256 title := r.FormValue("title")
1257 body := r.FormValue("body")
1258
1259 if title == "" || body == "" {
1260 s.pages.Notice(w, "issues", "Title and body are required")
1261 return
1262 }
1263
1264 tx, err := s.db.BeginTx(r.Context(), nil)
1265 if err != nil {
1266 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1267 return
1268 }
1269
1270 err = db.NewIssue(tx, &db.Issue{
1271 RepoAt: f.RepoAt,
1272 Title: title,
1273 Body: body,
1274 OwnerDid: user.Did,
1275 })
1276 if err != nil {
1277 log.Println("failed to create issue", err)
1278 s.pages.Notice(w, "issues", "Failed to create issue.")
1279 return
1280 }
1281
1282 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1283 if err != nil {
1284 log.Println("failed to get issue id", err)
1285 s.pages.Notice(w, "issues", "Failed to create issue.")
1286 return
1287 }
1288
1289 client, _ := s.auth.AuthorizedClient(r)
1290 atUri := f.RepoAt.String()
1291 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1292 Collection: tangled.RepoIssueNSID,
1293 Repo: user.Did,
1294 Rkey: s.TID(),
1295 Record: &lexutil.LexiconTypeDecoder{
1296 Val: &tangled.RepoIssue{
1297 Repo: atUri,
1298 Title: title,
1299 Body: &body,
1300 Owner: user.Did,
1301 IssueId: int64(issueId),
1302 },
1303 },
1304 })
1305 if err != nil {
1306 log.Println("failed to create issue", err)
1307 s.pages.Notice(w, "issues", "Failed to create issue.")
1308 return
1309 }
1310
1311 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1312 if err != nil {
1313 log.Println("failed to set issue at", err)
1314 s.pages.Notice(w, "issues", "Failed to create issue.")
1315 return
1316 }
1317
1318 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1319 return
1320 }
1321}
1322
1323func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
1324 user := s.auth.GetUser(r)
1325 f, err := fullyResolvedRepo(r)
1326 if err != nil {
1327 log.Println("failed to get repo and knot", err)
1328 return
1329 }
1330
1331 pulls, err := db.GetPulls(s.db, f.RepoAt)
1332 if err != nil {
1333 log.Println("failed to get pulls", err)
1334 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
1335 return
1336 }
1337
1338 identsToResolve := make([]string, len(pulls))
1339 for i, pull := range pulls {
1340 identsToResolve[i] = pull.OwnerDid
1341 }
1342 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1343 didHandleMap := make(map[string]string)
1344 for _, identity := range resolvedIds {
1345 if !identity.Handle.IsInvalidHandle() {
1346 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1347 } else {
1348 didHandleMap[identity.DID.String()] = identity.DID.String()
1349 }
1350 }
1351
1352 s.pages.RepoPulls(w, pages.RepoPullsParams{
1353 LoggedInUser: s.auth.GetUser(r),
1354 RepoInfo: f.RepoInfo(s, user),
1355 Pulls: pulls,
1356 DidHandleMap: didHandleMap,
1357 })
1358 return
1359}
1360
1361func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
1362 repoName := chi.URLParam(r, "repo")
1363 knot, ok := r.Context().Value("knot").(string)
1364 if !ok {
1365 log.Println("malformed middleware")
1366 return nil, fmt.Errorf("malformed middleware")
1367 }
1368 id, ok := r.Context().Value("resolvedId").(identity.Identity)
1369 if !ok {
1370 log.Println("malformed middleware")
1371 return nil, fmt.Errorf("malformed middleware")
1372 }
1373
1374 repoAt, ok := r.Context().Value("repoAt").(string)
1375 if !ok {
1376 log.Println("malformed middleware")
1377 return nil, fmt.Errorf("malformed middleware")
1378 }
1379
1380 parsedRepoAt, err := syntax.ParseATURI(repoAt)
1381 if err != nil {
1382 log.Println("malformed repo at-uri")
1383 return nil, fmt.Errorf("malformed middleware")
1384 }
1385
1386 // pass through values from the middleware
1387 description, ok := r.Context().Value("repoDescription").(string)
1388 addedAt, ok := r.Context().Value("repoAddedAt").(string)
1389
1390 return &FullyResolvedRepo{
1391 Knot: knot,
1392 OwnerId: id,
1393 RepoName: repoName,
1394 RepoAt: parsedRepoAt,
1395 Description: description,
1396 AddedAt: addedAt,
1397 }, nil
1398}
1399
1400func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
1401 if u != nil {
1402 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
1403 return pages.RolesInRepo{r}
1404 } else {
1405 return pages.RolesInRepo{}
1406 }
1407}