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: issue.OwnerDid,
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 if user.Did == f.OwnerDid() {
1103 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1104 if err != nil {
1105 log.Println("failed to reopen issue", err)
1106 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1107 return
1108 }
1109 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1110 return
1111 } else {
1112 log.Println("user is not the owner of the repo")
1113 http.Error(w, "forbidden", http.StatusUnauthorized)
1114 return
1115 }
1116}
1117
1118func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1119 user := s.auth.GetUser(r)
1120 f, err := fullyResolvedRepo(r)
1121 if err != nil {
1122 log.Println("failed to get repo and knot", err)
1123 return
1124 }
1125
1126 issueId := chi.URLParam(r, "issue")
1127 issueIdInt, err := strconv.Atoi(issueId)
1128 if err != nil {
1129 http.Error(w, "bad issue id", http.StatusBadRequest)
1130 log.Println("failed to parse issue id", err)
1131 return
1132 }
1133
1134 switch r.Method {
1135 case http.MethodPost:
1136 body := r.FormValue("body")
1137 if body == "" {
1138 s.pages.Notice(w, "issue", "Body is required")
1139 return
1140 }
1141
1142 commentId := rand.IntN(1000000)
1143
1144 err := db.NewComment(s.db, &db.Comment{
1145 OwnerDid: user.Did,
1146 RepoAt: f.RepoAt,
1147 Issue: issueIdInt,
1148 CommentId: commentId,
1149 Body: body,
1150 })
1151 if err != nil {
1152 log.Println("failed to create comment", err)
1153 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1154 return
1155 }
1156
1157 createdAt := time.Now().Format(time.RFC3339)
1158 commentIdInt64 := int64(commentId)
1159 ownerDid := user.Did
1160 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1161 if err != nil {
1162 log.Println("failed to get issue at", err)
1163 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1164 return
1165 }
1166
1167 atUri := f.RepoAt.String()
1168 client, _ := s.auth.AuthorizedClient(r)
1169 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1170 Collection: tangled.RepoIssueCommentNSID,
1171 Repo: user.Did,
1172 Rkey: s.TID(),
1173 Record: &lexutil.LexiconTypeDecoder{
1174 Val: &tangled.RepoIssueComment{
1175 Repo: &atUri,
1176 Issue: issueAt,
1177 CommentId: &commentIdInt64,
1178 Owner: &ownerDid,
1179 Body: &body,
1180 CreatedAt: &createdAt,
1181 },
1182 },
1183 })
1184 if err != nil {
1185 log.Println("failed to create comment", err)
1186 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1187 return
1188 }
1189
1190 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1191 return
1192 }
1193}
1194
1195func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1196 params := r.URL.Query()
1197 state := params.Get("state")
1198 isOpen := true
1199 switch state {
1200 case "open":
1201 isOpen = true
1202 case "closed":
1203 isOpen = false
1204 default:
1205 isOpen = true
1206 }
1207
1208 user := s.auth.GetUser(r)
1209 f, err := fullyResolvedRepo(r)
1210 if err != nil {
1211 log.Println("failed to get repo and knot", err)
1212 return
1213 }
1214
1215 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1216 if err != nil {
1217 log.Println("failed to get issues", err)
1218 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1219 return
1220 }
1221
1222 identsToResolve := make([]string, len(issues))
1223 for i, issue := range issues {
1224 identsToResolve[i] = issue.OwnerDid
1225 }
1226 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1227 didHandleMap := make(map[string]string)
1228 for _, identity := range resolvedIds {
1229 if !identity.Handle.IsInvalidHandle() {
1230 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1231 } else {
1232 didHandleMap[identity.DID.String()] = identity.DID.String()
1233 }
1234 }
1235
1236 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1237 LoggedInUser: s.auth.GetUser(r),
1238 RepoInfo: f.RepoInfo(s, user),
1239 Issues: issues,
1240 DidHandleMap: didHandleMap,
1241 FilteringByOpen: isOpen,
1242 })
1243 return
1244}
1245
1246func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1247 user := s.auth.GetUser(r)
1248
1249 f, err := fullyResolvedRepo(r)
1250 if err != nil {
1251 log.Println("failed to get repo and knot", err)
1252 return
1253 }
1254
1255 switch r.Method {
1256 case http.MethodGet:
1257 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1258 LoggedInUser: user,
1259 RepoInfo: f.RepoInfo(s, user),
1260 })
1261 case http.MethodPost:
1262 title := r.FormValue("title")
1263 body := r.FormValue("body")
1264
1265 if title == "" || body == "" {
1266 s.pages.Notice(w, "issues", "Title and body are required")
1267 return
1268 }
1269
1270 tx, err := s.db.BeginTx(r.Context(), nil)
1271 if err != nil {
1272 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1273 return
1274 }
1275
1276 err = db.NewIssue(tx, &db.Issue{
1277 RepoAt: f.RepoAt,
1278 Title: title,
1279 Body: body,
1280 OwnerDid: user.Did,
1281 })
1282 if err != nil {
1283 log.Println("failed to create issue", err)
1284 s.pages.Notice(w, "issues", "Failed to create issue.")
1285 return
1286 }
1287
1288 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1289 if err != nil {
1290 log.Println("failed to get issue id", err)
1291 s.pages.Notice(w, "issues", "Failed to create issue.")
1292 return
1293 }
1294
1295 client, _ := s.auth.AuthorizedClient(r)
1296 atUri := f.RepoAt.String()
1297 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1298 Collection: tangled.RepoIssueNSID,
1299 Repo: user.Did,
1300 Rkey: s.TID(),
1301 Record: &lexutil.LexiconTypeDecoder{
1302 Val: &tangled.RepoIssue{
1303 Repo: atUri,
1304 Title: title,
1305 Body: &body,
1306 Owner: user.Did,
1307 IssueId: int64(issueId),
1308 },
1309 },
1310 })
1311 if err != nil {
1312 log.Println("failed to create issue", err)
1313 s.pages.Notice(w, "issues", "Failed to create issue.")
1314 return
1315 }
1316
1317 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1318 if err != nil {
1319 log.Println("failed to set issue at", err)
1320 s.pages.Notice(w, "issues", "Failed to create issue.")
1321 return
1322 }
1323
1324 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1325 return
1326 }
1327}
1328
1329func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
1330 user := s.auth.GetUser(r)
1331 f, err := fullyResolvedRepo(r)
1332 if err != nil {
1333 log.Println("failed to get repo and knot", err)
1334 return
1335 }
1336
1337 pulls, err := db.GetPulls(s.db, f.RepoAt)
1338 if err != nil {
1339 log.Println("failed to get pulls", err)
1340 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
1341 return
1342 }
1343
1344 identsToResolve := make([]string, len(pulls))
1345 for i, pull := range pulls {
1346 identsToResolve[i] = pull.OwnerDid
1347 }
1348 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1349 didHandleMap := make(map[string]string)
1350 for _, identity := range resolvedIds {
1351 if !identity.Handle.IsInvalidHandle() {
1352 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1353 } else {
1354 didHandleMap[identity.DID.String()] = identity.DID.String()
1355 }
1356 }
1357
1358 s.pages.RepoPulls(w, pages.RepoPullsParams{
1359 LoggedInUser: s.auth.GetUser(r),
1360 RepoInfo: f.RepoInfo(s, user),
1361 Pulls: pulls,
1362 DidHandleMap: didHandleMap,
1363 })
1364 return
1365}
1366
1367func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
1368 repoName := chi.URLParam(r, "repo")
1369 knot, ok := r.Context().Value("knot").(string)
1370 if !ok {
1371 log.Println("malformed middleware")
1372 return nil, fmt.Errorf("malformed middleware")
1373 }
1374 id, ok := r.Context().Value("resolvedId").(identity.Identity)
1375 if !ok {
1376 log.Println("malformed middleware")
1377 return nil, fmt.Errorf("malformed middleware")
1378 }
1379
1380 repoAt, ok := r.Context().Value("repoAt").(string)
1381 if !ok {
1382 log.Println("malformed middleware")
1383 return nil, fmt.Errorf("malformed middleware")
1384 }
1385
1386 parsedRepoAt, err := syntax.ParseATURI(repoAt)
1387 if err != nil {
1388 log.Println("malformed repo at-uri")
1389 return nil, fmt.Errorf("malformed middleware")
1390 }
1391
1392 // pass through values from the middleware
1393 description, ok := r.Context().Value("repoDescription").(string)
1394 addedAt, ok := r.Context().Value("repoAddedAt").(string)
1395
1396 return &FullyResolvedRepo{
1397 Knot: knot,
1398 OwnerId: id,
1399 RepoName: repoName,
1400 RepoAt: parsedRepoAt,
1401 Description: description,
1402 AddedAt: addedAt,
1403 }, nil
1404}
1405
1406func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
1407 if u != nil {
1408 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
1409 return pages.RolesInRepo{r}
1410 } else {
1411 return pages.RolesInRepo{}
1412 }
1413}