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