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 securejoin "github.com/cyphar/filepath-securejoin"
19 "github.com/go-chi/chi/v5"
20 "github.com/sotangled/tangled/api/tangled"
21 "github.com/sotangled/tangled/appview/auth"
22 "github.com/sotangled/tangled/appview/db"
23 "github.com/sotangled/tangled/appview/pages"
24 "github.com/sotangled/tangled/types"
25
26 comatproto "github.com/bluesky-social/indigo/api/atproto"
27 lexutil "github.com/bluesky-social/indigo/lex/util"
28)
29
30func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
31 ref := chi.URLParam(r, "ref")
32 f, err := fullyResolvedRepo(r)
33 if err != nil {
34 log.Println("failed to fully resolve repo", err)
35 return
36 }
37 var reqUrl string
38 if ref != "" {
39 reqUrl = fmt.Sprintf("http://%s/%s/%s/tree/%s", f.Knot, f.OwnerDid(), f.RepoName, ref)
40 } else {
41 reqUrl = fmt.Sprintf("http://%s/%s/%s", f.Knot, f.OwnerDid(), f.RepoName)
42 }
43
44 resp, err := http.Get(reqUrl)
45 if err != nil {
46 s.pages.Error503(w)
47 log.Println("failed to reach knotserver", err)
48 return
49 }
50 defer resp.Body.Close()
51
52 body, err := io.ReadAll(resp.Body)
53 if err != nil {
54 log.Printf("Error reading response body: %v", err)
55 return
56 }
57
58 var result types.RepoIndexResponse
59 err = json.Unmarshal(body, &result)
60 if err != nil {
61 log.Printf("Error unmarshalling response body: %v", err)
62 return
63 }
64
65 tagMap := make(map[string][]string)
66 for _, tag := range result.Tags {
67 hash := tag.Hash
68 tagMap[hash] = append(tagMap[hash], tag.Name)
69 }
70
71 for _, branch := range result.Branches {
72 hash := branch.Hash
73 tagMap[hash] = append(tagMap[hash], branch.Name)
74 }
75
76 user := s.auth.GetUser(r)
77 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
78 LoggedInUser: user,
79 RepoInfo: pages.RepoInfo{
80 OwnerDid: f.OwnerDid(),
81 OwnerHandle: f.OwnerHandle(),
82 Name: f.RepoName,
83 SettingsAllowed: settingsAllowed(s, user, f),
84 },
85 TagMap: tagMap,
86 RepoIndexResponse: result,
87 })
88
89 return
90}
91
92func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
93 f, err := fullyResolvedRepo(r)
94 if err != nil {
95 log.Println("failed to fully resolve repo", err)
96 return
97 }
98
99 page := 1
100 if r.URL.Query().Get("page") != "" {
101 page, err = strconv.Atoi(r.URL.Query().Get("page"))
102 if err != nil {
103 page = 1
104 }
105 }
106
107 ref := chi.URLParam(r, "ref")
108 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/log/%s?page=%d&per_page=30", f.Knot, f.OwnerDid(), f.RepoName, ref, page))
109 if err != nil {
110 log.Println("failed to reach knotserver", err)
111 return
112 }
113
114 body, err := io.ReadAll(resp.Body)
115 if err != nil {
116 log.Printf("error reading response body: %v", err)
117 return
118 }
119
120 var repolog types.RepoLogResponse
121 err = json.Unmarshal(body, &repolog)
122 if err != nil {
123 log.Println("failed to parse json response", err)
124 return
125 }
126
127 user := s.auth.GetUser(r)
128 s.pages.RepoLog(w, pages.RepoLogParams{
129 LoggedInUser: user,
130 RepoInfo: pages.RepoInfo{
131 OwnerDid: f.OwnerDid(),
132 OwnerHandle: f.OwnerHandle(),
133 Name: f.RepoName,
134 SettingsAllowed: settingsAllowed(s, user, f),
135 },
136 RepoLogResponse: repolog,
137 })
138 return
139}
140
141func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
142 f, err := fullyResolvedRepo(r)
143 if err != nil {
144 log.Println("failed to fully resolve repo", err)
145 return
146 }
147
148 ref := chi.URLParam(r, "ref")
149 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/commit/%s", f.Knot, f.OwnerDid(), f.RepoName, ref))
150 if err != nil {
151 log.Println("failed to reach knotserver", err)
152 return
153 }
154
155 body, err := io.ReadAll(resp.Body)
156 if err != nil {
157 log.Printf("Error reading response body: %v", err)
158 return
159 }
160
161 var result types.RepoCommitResponse
162 err = json.Unmarshal(body, &result)
163 if err != nil {
164 log.Println("failed to parse response:", err)
165 return
166 }
167
168 user := s.auth.GetUser(r)
169 s.pages.RepoCommit(w, pages.RepoCommitParams{
170 LoggedInUser: user,
171 RepoInfo: pages.RepoInfo{
172 OwnerDid: f.OwnerDid(),
173 OwnerHandle: f.OwnerHandle(),
174 Name: f.RepoName,
175 SettingsAllowed: settingsAllowed(s, user, f),
176 },
177 RepoCommitResponse: result,
178 })
179 return
180}
181
182func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
183 f, err := fullyResolvedRepo(r)
184 if err != nil {
185 log.Println("failed to fully resolve repo", err)
186 return
187 }
188
189 ref := chi.URLParam(r, "ref")
190 treePath := chi.URLParam(r, "*")
191 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/tree/%s/%s", f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
192 if err != nil {
193 log.Println("failed to reach knotserver", err)
194 return
195 }
196
197 body, err := io.ReadAll(resp.Body)
198 if err != nil {
199 log.Printf("Error reading response body: %v", err)
200 return
201 }
202
203 var result types.RepoTreeResponse
204 err = json.Unmarshal(body, &result)
205 if err != nil {
206 log.Println("failed to parse response:", err)
207 return
208 }
209
210 user := s.auth.GetUser(r)
211
212 var breadcrumbs [][]string
213 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
214 if treePath != "" {
215 for idx, elem := range strings.Split(treePath, "/") {
216 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
217 }
218 }
219
220 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
221 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
222
223 s.pages.RepoTree(w, pages.RepoTreeParams{
224 LoggedInUser: user,
225 BreadCrumbs: breadcrumbs,
226 BaseTreeLink: baseTreeLink,
227 BaseBlobLink: baseBlobLink,
228 RepoInfo: pages.RepoInfo{
229 OwnerDid: f.OwnerDid(),
230 OwnerHandle: f.OwnerHandle(),
231 Name: f.RepoName,
232 SettingsAllowed: settingsAllowed(s, user, f),
233 },
234 RepoTreeResponse: result,
235 })
236 return
237}
238
239func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
240 f, err := fullyResolvedRepo(r)
241 if err != nil {
242 log.Println("failed to get repo and knot", err)
243 return
244 }
245
246 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/tags", f.Knot, f.OwnerDid(), f.RepoName))
247 if err != nil {
248 log.Println("failed to reach knotserver", err)
249 return
250 }
251
252 body, err := io.ReadAll(resp.Body)
253 if err != nil {
254 log.Printf("Error reading response body: %v", err)
255 return
256 }
257
258 var result types.RepoTagsResponse
259 err = json.Unmarshal(body, &result)
260 if err != nil {
261 log.Println("failed to parse response:", err)
262 return
263 }
264
265 user := s.auth.GetUser(r)
266 s.pages.RepoTags(w, pages.RepoTagsParams{
267 LoggedInUser: user,
268 RepoInfo: pages.RepoInfo{
269 OwnerDid: f.OwnerDid(),
270 OwnerHandle: f.OwnerHandle(),
271 Name: f.RepoName,
272 SettingsAllowed: settingsAllowed(s, user, f),
273 },
274 RepoTagsResponse: result,
275 })
276 return
277}
278
279func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
280 f, err := fullyResolvedRepo(r)
281 if err != nil {
282 log.Println("failed to get repo and knot", err)
283 return
284 }
285
286 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/branches", f.Knot, f.OwnerDid(), f.RepoName))
287 if err != nil {
288 log.Println("failed to reach knotserver", err)
289 return
290 }
291
292 body, err := io.ReadAll(resp.Body)
293 if err != nil {
294 log.Printf("Error reading response body: %v", err)
295 return
296 }
297
298 var result types.RepoBranchesResponse
299 err = json.Unmarshal(body, &result)
300 if err != nil {
301 log.Println("failed to parse response:", err)
302 return
303 }
304
305 user := s.auth.GetUser(r)
306 s.pages.RepoBranches(w, pages.RepoBranchesParams{
307 LoggedInUser: user,
308 RepoInfo: pages.RepoInfo{
309 OwnerDid: f.OwnerDid(),
310 OwnerHandle: f.OwnerHandle(),
311 Name: f.RepoName,
312 SettingsAllowed: settingsAllowed(s, user, f),
313 },
314 RepoBranchesResponse: result,
315 })
316 return
317}
318
319func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
320 f, err := fullyResolvedRepo(r)
321 if err != nil {
322 log.Println("failed to get repo and knot", err)
323 return
324 }
325
326 ref := chi.URLParam(r, "ref")
327 filePath := chi.URLParam(r, "*")
328 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/blob/%s/%s", f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
329 if err != nil {
330 log.Println("failed to reach knotserver", err)
331 return
332 }
333
334 body, err := io.ReadAll(resp.Body)
335 if err != nil {
336 log.Printf("Error reading response body: %v", err)
337 return
338 }
339
340 var result types.RepoBlobResponse
341 err = json.Unmarshal(body, &result)
342 if err != nil {
343 log.Println("failed to parse response:", err)
344 return
345 }
346
347 var breadcrumbs [][]string
348 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
349 if filePath != "" {
350 for idx, elem := range strings.Split(filePath, "/") {
351 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
352 }
353 }
354
355 user := s.auth.GetUser(r)
356 s.pages.RepoBlob(w, pages.RepoBlobParams{
357 LoggedInUser: user,
358 RepoInfo: pages.RepoInfo{
359 OwnerDid: f.OwnerDid(),
360 OwnerHandle: f.OwnerHandle(),
361 Name: f.RepoName,
362 SettingsAllowed: settingsAllowed(s, user, f),
363 },
364 RepoBlobResponse: result,
365 BreadCrumbs: breadcrumbs,
366 })
367 return
368}
369
370func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
371 f, err := fullyResolvedRepo(r)
372 if err != nil {
373 log.Println("failed to get repo and knot", err)
374 return
375 }
376
377 collaborator := r.FormValue("collaborator")
378 if collaborator == "" {
379 http.Error(w, "malformed form", http.StatusBadRequest)
380 return
381 }
382
383 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
384 if err != nil {
385 w.Write([]byte("failed to resolve collaborator did to a handle"))
386 return
387 }
388 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
389
390 // TODO: create an atproto record for this
391
392 secret, err := db.GetRegistrationKey(s.db, f.Knot)
393 if err != nil {
394 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
395 return
396 }
397
398 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
399 if err != nil {
400 log.Println("failed to create client to ", f.Knot)
401 return
402 }
403
404 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
405 if err != nil {
406 log.Printf("failed to make request to %s: %s", f.Knot, err)
407 return
408 }
409
410 if ksResp.StatusCode != http.StatusNoContent {
411 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
412 return
413 }
414
415 tx, err := s.db.BeginTx(r.Context(), nil)
416 if err != nil {
417 log.Println("failed to start tx")
418 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
419 return
420 }
421 defer func() {
422 tx.Rollback()
423 err = s.enforcer.E.LoadPolicy()
424 if err != nil {
425 log.Println("failed to rollback policies")
426 }
427 }()
428
429 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
430 if err != nil {
431 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
432 return
433 }
434
435 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
436 if err != nil {
437 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
438 return
439 }
440
441 err = tx.Commit()
442 if err != nil {
443 log.Println("failed to commit changes", err)
444 http.Error(w, err.Error(), http.StatusInternalServerError)
445 return
446 }
447
448 err = s.enforcer.E.SavePolicy()
449 if err != nil {
450 log.Println("failed to update ACLs", err)
451 http.Error(w, err.Error(), http.StatusInternalServerError)
452 return
453 }
454
455 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
456
457}
458
459func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
460 f, err := fullyResolvedRepo(r)
461 if err != nil {
462 log.Println("failed to get repo and knot", err)
463 return
464 }
465
466 switch r.Method {
467 case http.MethodGet:
468 // for now, this is just pubkeys
469 user := s.auth.GetUser(r)
470 repoCollaborators, err := f.Collaborators(r.Context(), s)
471 if err != nil {
472 log.Println("failed to get collaborators", err)
473 }
474
475 isCollaboratorInviteAllowed := false
476 if user != nil {
477 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
478 if err == nil && ok {
479 isCollaboratorInviteAllowed = true
480 }
481 }
482
483 s.pages.RepoSettings(w, pages.RepoSettingsParams{
484 LoggedInUser: user,
485 RepoInfo: pages.RepoInfo{
486 OwnerDid: f.OwnerDid(),
487 OwnerHandle: f.OwnerHandle(),
488 Name: f.RepoName,
489 SettingsAllowed: settingsAllowed(s, user, f),
490 },
491 Collaborators: repoCollaborators,
492 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
493 })
494 }
495}
496
497type FullyResolvedRepo struct {
498 Knot string
499 OwnerId identity.Identity
500 RepoName string
501 RepoAt string
502}
503
504func (f *FullyResolvedRepo) OwnerDid() string {
505 return f.OwnerId.DID.String()
506}
507
508func (f *FullyResolvedRepo) OwnerHandle() string {
509 return f.OwnerId.Handle.String()
510}
511
512func (f *FullyResolvedRepo) OwnerSlashRepo() string {
513 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
514 return p
515}
516
517func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
518 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
519 if err != nil {
520 return nil, err
521 }
522
523 var collaborators []pages.Collaborator
524 for _, item := range repoCollaborators {
525 // currently only two roles: owner and member
526 var role string
527 if item[3] == "repo:owner" {
528 role = "owner"
529 } else if item[3] == "repo:collaborator" {
530 role = "collaborator"
531 } else {
532 continue
533 }
534
535 did := item[0]
536
537 c := pages.Collaborator{
538 Did: did,
539 Handle: "",
540 Role: role,
541 }
542 collaborators = append(collaborators, c)
543 }
544
545 // populate all collborators with handles
546 identsToResolve := make([]string, len(collaborators))
547 for i, collab := range collaborators {
548 identsToResolve[i] = collab.Did
549 }
550
551 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
552 for i, resolved := range resolvedIdents {
553 if resolved != nil {
554 collaborators[i].Handle = resolved.Handle.String()
555 }
556 }
557
558 return collaborators, nil
559}
560
561func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
562 user := s.auth.GetUser(r)
563 f, err := fullyResolvedRepo(r)
564 if err != nil {
565 log.Println("failed to get repo and knot", err)
566 return
567 }
568
569 issueId := chi.URLParam(r, "issue")
570 issueIdInt, err := strconv.Atoi(issueId)
571 if err != nil {
572 http.Error(w, "bad issue id", http.StatusBadRequest)
573 log.Println("failed to parse issue id", err)
574 return
575 }
576
577 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
578 if err != nil {
579 log.Println("failed to get issue and comments", err)
580 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
581 return
582 }
583
584 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
585 if err != nil {
586 log.Println("failed to resolve issue owner", err)
587 }
588
589 identsToResolve := make([]string, len(comments))
590 for i, comment := range comments {
591 identsToResolve[i] = comment.OwnerDid
592 }
593 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
594 didHandleMap := make(map[string]string)
595 for _, identity := range resolvedIds {
596 if !identity.Handle.IsInvalidHandle() {
597 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
598 } else {
599 didHandleMap[identity.DID.String()] = identity.DID.String()
600 }
601 }
602
603 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
604 LoggedInUser: user,
605 RepoInfo: pages.RepoInfo{
606 OwnerDid: f.OwnerDid(),
607 OwnerHandle: f.OwnerHandle(),
608 Name: f.RepoName,
609 SettingsAllowed: settingsAllowed(s, user, f),
610 },
611 Issue: *issue,
612 Comments: comments,
613
614 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
615 DidHandleMap: didHandleMap,
616 })
617
618}
619
620func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
621 user := s.auth.GetUser(r)
622 f, err := fullyResolvedRepo(r)
623 if err != nil {
624 log.Println("failed to get repo and knot", err)
625 return
626 }
627
628 issueId := chi.URLParam(r, "issue")
629 issueIdInt, err := strconv.Atoi(issueId)
630 if err != nil {
631 http.Error(w, "bad issue id", http.StatusBadRequest)
632 log.Println("failed to parse issue id", err)
633 return
634 }
635
636 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
637 if err != nil {
638 log.Println("failed to get issue", err)
639 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
640 return
641 }
642
643 collaborators, err := f.Collaborators(r.Context(), s)
644 if err != nil {
645 log.Println("failed to fetch repo collaborators: %w", err)
646 }
647 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
648 return user.Did == collab.Did
649 })
650 isIssueOwner := user.Did == issue.OwnerDid
651
652 // TODO: make this more granular
653 if isIssueOwner || isCollaborator {
654
655 closed := tangled.RepoIssueStateClosed
656
657 client, _ := s.auth.AuthorizedClient(r)
658 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
659 Collection: tangled.RepoIssueStateNSID,
660 Repo: issue.OwnerDid,
661 Rkey: s.TID(),
662 Record: &lexutil.LexiconTypeDecoder{
663 Val: &tangled.RepoIssueState{
664 Issue: issue.IssueAt,
665 State: &closed,
666 },
667 },
668 })
669
670 if err != nil {
671 log.Println("failed to update issue state", err)
672 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
673 return
674 }
675
676 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
677 if err != nil {
678 log.Println("failed to close issue", err)
679 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
680 return
681 }
682
683 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
684 return
685 } else {
686 log.Println("user is not permitted to close issue")
687 http.Error(w, "for biden", http.StatusUnauthorized)
688 return
689 }
690}
691
692func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
693 user := s.auth.GetUser(r)
694 f, err := fullyResolvedRepo(r)
695 if err != nil {
696 log.Println("failed to get repo and knot", err)
697 return
698 }
699
700 issueId := chi.URLParam(r, "issue")
701 issueIdInt, err := strconv.Atoi(issueId)
702 if err != nil {
703 http.Error(w, "bad issue id", http.StatusBadRequest)
704 log.Println("failed to parse issue id", err)
705 return
706 }
707
708 if user.Did == f.OwnerDid() {
709 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
710 if err != nil {
711 log.Println("failed to reopen issue", err)
712 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
713 return
714 }
715 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
716 return
717 } else {
718 log.Println("user is not the owner of the repo")
719 http.Error(w, "forbidden", http.StatusUnauthorized)
720 return
721 }
722}
723
724func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
725 user := s.auth.GetUser(r)
726 f, err := fullyResolvedRepo(r)
727 if err != nil {
728 log.Println("failed to get repo and knot", err)
729 return
730 }
731
732 issueId := chi.URLParam(r, "issue")
733 issueIdInt, err := strconv.Atoi(issueId)
734 if err != nil {
735 http.Error(w, "bad issue id", http.StatusBadRequest)
736 log.Println("failed to parse issue id", err)
737 return
738 }
739
740 switch r.Method {
741 case http.MethodPost:
742 body := r.FormValue("body")
743 if body == "" {
744 s.pages.Notice(w, "issue", "Body is required")
745 return
746 }
747
748 commentId := rand.IntN(1000000)
749
750 err := db.NewComment(s.db, &db.Comment{
751 OwnerDid: user.Did,
752 RepoAt: f.RepoAt,
753 Issue: issueIdInt,
754 CommentId: commentId,
755 Body: body,
756 })
757 if err != nil {
758 log.Println("failed to create comment", err)
759 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
760 return
761 }
762
763 createdAt := time.Now().Format(time.RFC3339)
764 commentIdInt64 := int64(commentId)
765 ownerDid := user.Did
766 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
767 if err != nil {
768 log.Println("failed to get issue at", err)
769 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
770 return
771 }
772
773 client, _ := s.auth.AuthorizedClient(r)
774 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
775 Collection: tangled.RepoIssueCommentNSID,
776 Repo: user.Did,
777 Rkey: s.TID(),
778 Record: &lexutil.LexiconTypeDecoder{
779 Val: &tangled.RepoIssueComment{
780 Repo: &f.RepoAt,
781 Issue: issueAt,
782 CommentId: &commentIdInt64,
783 Owner: &ownerDid,
784 Body: &body,
785 CreatedAt: &createdAt,
786 },
787 },
788 })
789 if err != nil {
790 log.Println("failed to create comment", err)
791 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
792 return
793 }
794
795 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
796 return
797 }
798}
799
800func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
801 user := s.auth.GetUser(r)
802 f, err := fullyResolvedRepo(r)
803 if err != nil {
804 log.Println("failed to get repo and knot", err)
805 return
806 }
807
808 issues, err := db.GetIssues(s.db, f.RepoAt)
809 if err != nil {
810 log.Println("failed to get issues", err)
811 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
812 return
813 }
814
815 identsToResolve := make([]string, len(issues))
816 for i, issue := range issues {
817 identsToResolve[i] = issue.OwnerDid
818 }
819 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
820 didHandleMap := make(map[string]string)
821 for _, identity := range resolvedIds {
822 if !identity.Handle.IsInvalidHandle() {
823 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
824 } else {
825 didHandleMap[identity.DID.String()] = identity.DID.String()
826 }
827 }
828
829 s.pages.RepoIssues(w, pages.RepoIssuesParams{
830 LoggedInUser: s.auth.GetUser(r),
831 RepoInfo: pages.RepoInfo{
832 OwnerDid: f.OwnerDid(),
833 OwnerHandle: f.OwnerHandle(),
834 Name: f.RepoName,
835 SettingsAllowed: settingsAllowed(s, user, f),
836 },
837 Issues: issues,
838 DidHandleMap: didHandleMap,
839 })
840 return
841}
842
843func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
844 user := s.auth.GetUser(r)
845
846 f, err := fullyResolvedRepo(r)
847 if err != nil {
848 log.Println("failed to get repo and knot", err)
849 return
850 }
851
852 switch r.Method {
853 case http.MethodGet:
854 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
855 LoggedInUser: user,
856 RepoInfo: pages.RepoInfo{
857 Name: f.RepoName,
858 OwnerDid: f.OwnerDid(),
859 OwnerHandle: f.OwnerHandle(),
860 SettingsAllowed: settingsAllowed(s, user, f),
861 },
862 })
863 case http.MethodPost:
864 title := r.FormValue("title")
865 body := r.FormValue("body")
866
867 if title == "" || body == "" {
868 s.pages.Notice(w, "issues", "Title and body are required")
869 return
870 }
871
872 tx, err := s.db.BeginTx(r.Context(), nil)
873 if err != nil {
874 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
875 return
876 }
877
878 err = db.NewIssue(tx, &db.Issue{
879 RepoAt: f.RepoAt,
880 Title: title,
881 Body: body,
882 OwnerDid: user.Did,
883 })
884 if err != nil {
885 log.Println("failed to create issue", err)
886 s.pages.Notice(w, "issues", "Failed to create issue.")
887 return
888 }
889
890 issueId, err := db.GetIssueId(s.db, f.RepoAt)
891 if err != nil {
892 log.Println("failed to get issue id", err)
893 s.pages.Notice(w, "issues", "Failed to create issue.")
894 return
895 }
896
897 client, _ := s.auth.AuthorizedClient(r)
898 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
899 Collection: tangled.RepoIssueNSID,
900 Repo: user.Did,
901 Rkey: s.TID(),
902 Record: &lexutil.LexiconTypeDecoder{
903 Val: &tangled.RepoIssue{
904 Repo: f.RepoAt,
905 Title: title,
906 Body: &body,
907 Owner: user.Did,
908 IssueId: int64(issueId),
909 },
910 },
911 })
912 if err != nil {
913 log.Println("failed to create issue", err)
914 s.pages.Notice(w, "issues", "Failed to create issue.")
915 return
916 }
917
918 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
919 if err != nil {
920 log.Println("failed to set issue at", err)
921 s.pages.Notice(w, "issues", "Failed to create issue.")
922 return
923 }
924
925 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
926 return
927 }
928}
929
930func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
931 user := s.auth.GetUser(r)
932 f, err := fullyResolvedRepo(r)
933 if err != nil {
934 log.Println("failed to get repo and knot", err)
935 return
936 }
937
938 switch r.Method {
939 case http.MethodGet:
940 s.pages.RepoPulls(w, pages.RepoPullsParams{
941 LoggedInUser: user,
942 RepoInfo: pages.RepoInfo{
943 Name: f.RepoName,
944 OwnerDid: f.OwnerDid(),
945 OwnerHandle: f.OwnerHandle(),
946 SettingsAllowed: settingsAllowed(s, user, f),
947 },
948 })
949 }
950}
951
952func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
953 repoName := chi.URLParam(r, "repo")
954 knot, ok := r.Context().Value("knot").(string)
955 if !ok {
956 log.Println("malformed middleware")
957 return nil, fmt.Errorf("malformed middleware")
958 }
959 id, ok := r.Context().Value("resolvedId").(identity.Identity)
960 if !ok {
961 log.Println("malformed middleware")
962 return nil, fmt.Errorf("malformed middleware")
963 }
964
965 repoAt, ok := r.Context().Value("repoAt").(string)
966 if !ok {
967 log.Println("malformed middleware")
968 return nil, fmt.Errorf("malformed middleware")
969 }
970
971 return &FullyResolvedRepo{
972 Knot: knot,
973 OwnerId: id,
974 RepoName: repoName,
975 RepoAt: repoAt,
976 }, nil
977}
978
979func settingsAllowed(s *State, u *auth.User, f *FullyResolvedRepo) bool {
980 settingsAllowed := false
981 if u != nil {
982 ok, err := s.enforcer.IsSettingsAllowed(u.Did, f.Knot, f.OwnerSlashRepo())
983 if err == nil && ok {
984 settingsAllowed = true
985 } else {
986 log.Println(err, ok)
987 }
988 }
989
990 return settingsAllowed
991}