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