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