forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 mathrand "math/rand/v2"
12 "net/http"
13 "path"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "go.opentelemetry.io/otel/attribute"
20 "go.opentelemetry.io/otel/codes"
21 "tangled.sh/tangled.sh/core/api/tangled"
22 "tangled.sh/tangled.sh/core/appview"
23 "tangled.sh/tangled.sh/core/appview/auth"
24 "tangled.sh/tangled.sh/core/appview/db"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/appview/pages/markup"
27 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
28 "tangled.sh/tangled.sh/core/appview/pagination"
29 "tangled.sh/tangled.sh/core/types"
30
31 "github.com/bluesky-social/indigo/atproto/data"
32 "github.com/bluesky-social/indigo/atproto/identity"
33 "github.com/bluesky-social/indigo/atproto/syntax"
34 securejoin "github.com/cyphar/filepath-securejoin"
35 "github.com/go-chi/chi/v5"
36 "github.com/go-git/go-git/v5/plumbing"
37
38 comatproto "github.com/bluesky-social/indigo/api/atproto"
39 lexutil "github.com/bluesky-social/indigo/lex/util"
40)
41
42func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
43 ctx, span := s.t.TraceStart(r.Context(), "RepoIndex")
44 defer span.End()
45
46 ref := chi.URLParam(r, "ref")
47 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
48 if err != nil {
49 log.Println("failed to fully resolve repo", err)
50 span.RecordError(err)
51 span.SetStatus(codes.Error, "failed to fully resolve repo")
52 return
53 }
54
55 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
56 if err != nil {
57 log.Printf("failed to create unsigned client for %s", f.Knot)
58 span.RecordError(err)
59 span.SetStatus(codes.Error, "failed to create unsigned client")
60 s.pages.Error503(w)
61 return
62 }
63
64 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
65 if err != nil {
66 s.pages.Error503(w)
67 log.Println("failed to reach knotserver", err)
68 span.RecordError(err)
69 span.SetStatus(codes.Error, "failed to reach knotserver")
70 return
71 }
72 defer resp.Body.Close()
73
74 body, err := io.ReadAll(resp.Body)
75 if err != nil {
76 log.Printf("Error reading response body: %v", err)
77 span.RecordError(err)
78 span.SetStatus(codes.Error, "error reading response body")
79 return
80 }
81
82 var result types.RepoIndexResponse
83 err = json.Unmarshal(body, &result)
84 if err != nil {
85 log.Printf("Error unmarshalling response body: %v", err)
86 span.RecordError(err)
87 span.SetStatus(codes.Error, "error unmarshalling response body")
88 return
89 }
90
91 tagMap := make(map[string][]string)
92 for _, tag := range result.Tags {
93 hash := tag.Hash
94 if tag.Tag != nil {
95 hash = tag.Tag.Target.String()
96 }
97 tagMap[hash] = append(tagMap[hash], tag.Name)
98 }
99
100 for _, branch := range result.Branches {
101 hash := branch.Hash
102 tagMap[hash] = append(tagMap[hash], branch.Name)
103 }
104
105 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
106 if a.Name == result.Ref {
107 return -1
108 }
109 if a.IsDefault {
110 return -1
111 }
112 if b.IsDefault {
113 return 1
114 }
115 if a.Commit != nil {
116 if a.Commit.Author.When.Before(b.Commit.Author.When) {
117 return 1
118 } else {
119 return -1
120 }
121 }
122 return strings.Compare(a.Name, b.Name) * -1
123 })
124
125 commitCount := len(result.Commits)
126 branchCount := len(result.Branches)
127 tagCount := len(result.Tags)
128 fileCount := len(result.Files)
129
130 span.SetAttributes(
131 attribute.Int("commits.count", commitCount),
132 attribute.Int("branches.count", branchCount),
133 attribute.Int("tags.count", tagCount),
134 attribute.Int("files.count", fileCount),
135 )
136
137 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
138 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
139 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
140 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
141
142 emails := uniqueEmails(commitsTrunc)
143
144 user := s.auth.GetUser(r)
145 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
146 LoggedInUser: user,
147 RepoInfo: f.RepoInfo(ctx, s, user),
148 TagMap: tagMap,
149 RepoIndexResponse: result,
150 CommitsTrunc: commitsTrunc,
151 TagsTrunc: tagsTrunc,
152 BranchesTrunc: branchesTrunc,
153 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
154 })
155 return
156}
157
158func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
159 ctx, span := s.t.TraceStart(r.Context(), "RepoLog")
160 defer span.End()
161
162 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
163 if err != nil {
164 log.Println("failed to fully resolve repo", err)
165 span.RecordError(err)
166 span.SetStatus(codes.Error, "failed to fully resolve repo")
167 return
168 }
169
170 page := 1
171 if r.URL.Query().Get("page") != "" {
172 page, err = strconv.Atoi(r.URL.Query().Get("page"))
173 if err != nil {
174 page = 1
175 }
176 }
177
178 ref := chi.URLParam(r, "ref")
179 span.SetAttributes(attribute.Int("page", page), attribute.String("ref", ref))
180
181 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
182 if err != nil {
183 log.Println("failed to create unsigned client", err)
184 span.RecordError(err)
185 span.SetStatus(codes.Error, "failed to create unsigned client")
186 return
187 }
188
189 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
190 if err != nil {
191 log.Println("failed to reach knotserver", err)
192 span.RecordError(err)
193 span.SetStatus(codes.Error, "failed to reach knotserver")
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 span.RecordError(err)
201 span.SetStatus(codes.Error, "error reading response body")
202 return
203 }
204
205 var repolog types.RepoLogResponse
206 err = json.Unmarshal(body, &repolog)
207 if err != nil {
208 log.Println("failed to parse json response", err)
209 span.RecordError(err)
210 span.SetStatus(codes.Error, "failed to parse json response")
211 return
212 }
213
214 span.SetAttributes(attribute.Int("commits.count", len(repolog.Commits)))
215
216 result, err := us.Tags(f.OwnerDid(), f.RepoName)
217 if err != nil {
218 log.Println("failed to reach knotserver", err)
219 span.RecordError(err)
220 span.SetStatus(codes.Error, "failed to reach knotserver for tags")
221 return
222 }
223
224 tagMap := make(map[string][]string)
225 for _, tag := range result.Tags {
226 hash := tag.Hash
227 if tag.Tag != nil {
228 hash = tag.Tag.Target.String()
229 }
230 tagMap[hash] = append(tagMap[hash], tag.Name)
231 }
232
233 span.SetAttributes(attribute.Int("tags.count", len(result.Tags)))
234
235 user := s.auth.GetUser(r)
236 s.pages.RepoLog(w, pages.RepoLogParams{
237 LoggedInUser: user,
238 TagMap: tagMap,
239 RepoInfo: f.RepoInfo(ctx, s, user),
240 RepoLogResponse: repolog,
241 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
242 })
243 return
244}
245
246func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
247 ctx, span := s.t.TraceStart(r.Context(), "RepoDescriptionEdit")
248 defer span.End()
249
250 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
251 if err != nil {
252 log.Println("failed to get repo and knot", err)
253 w.WriteHeader(http.StatusBadRequest)
254 return
255 }
256
257 user := s.auth.GetUser(r)
258 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
259 RepoInfo: f.RepoInfo(ctx, s, user),
260 })
261 return
262}
263
264func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
265 ctx, span := s.t.TraceStart(r.Context(), "RepoDescription")
266 defer span.End()
267
268 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
269 if err != nil {
270 log.Println("failed to get repo and knot", err)
271 span.RecordError(err)
272 span.SetStatus(codes.Error, "failed to resolve repo")
273 w.WriteHeader(http.StatusBadRequest)
274 return
275 }
276
277 repoAt := f.RepoAt
278 rkey := repoAt.RecordKey().String()
279 if rkey == "" {
280 log.Println("invalid aturi for repo", err)
281 span.RecordError(err)
282 span.SetStatus(codes.Error, "invalid aturi for repo")
283 w.WriteHeader(http.StatusInternalServerError)
284 return
285 }
286
287 user := s.auth.GetUser(r)
288 span.SetAttributes(attribute.String("method", r.Method))
289
290 switch r.Method {
291 case http.MethodGet:
292 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
293 RepoInfo: f.RepoInfo(ctx, s, user),
294 })
295 return
296 case http.MethodPut:
297 user := s.auth.GetUser(r)
298 newDescription := r.FormValue("description")
299 span.SetAttributes(attribute.String("description", newDescription))
300 client, _ := s.auth.AuthorizedClient(r)
301
302 // optimistic update
303 err = db.UpdateDescription(ctx, s.db, string(repoAt), newDescription)
304 if err != nil {
305 log.Println("failed to perform update-description query", err)
306 span.RecordError(err)
307 span.SetStatus(codes.Error, "failed to update description in database")
308 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
309 return
310 }
311
312 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
313 //
314 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
315 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoNSID, user.Did, rkey)
316 if err != nil {
317 // failed to get record
318 span.RecordError(err)
319 span.SetStatus(codes.Error, "failed to get record from PDS")
320 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
321 return
322 }
323
324 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
325 Collection: tangled.RepoNSID,
326 Repo: user.Did,
327 Rkey: rkey,
328 SwapRecord: ex.Cid,
329 Record: &lexutil.LexiconTypeDecoder{
330 Val: &tangled.Repo{
331 Knot: f.Knot,
332 Name: f.RepoName,
333 Owner: user.Did,
334 CreatedAt: f.CreatedAt,
335 Description: &newDescription,
336 },
337 },
338 })
339
340 if err != nil {
341 log.Println("failed to perform update-description query", err)
342 span.RecordError(err)
343 span.SetStatus(codes.Error, "failed to put record to PDS")
344 // failed to get record
345 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
346 return
347 }
348
349 newRepoInfo := f.RepoInfo(ctx, s, user)
350 newRepoInfo.Description = newDescription
351
352 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
353 RepoInfo: newRepoInfo,
354 })
355 return
356 }
357}
358
359func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
360 ctx, span := s.t.TraceStart(r.Context(), "RepoCommit")
361 defer span.End()
362
363 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
364 if err != nil {
365 log.Println("failed to fully resolve repo", err)
366 span.RecordError(err)
367 span.SetStatus(codes.Error, "failed to fully resolve repo")
368 return
369 }
370 ref := chi.URLParam(r, "ref")
371 protocol := "http"
372 if !s.config.Dev {
373 protocol = "https"
374 }
375
376 span.SetAttributes(attribute.String("ref", ref), attribute.String("protocol", protocol))
377
378 if !plumbing.IsHash(ref) {
379 span.SetAttributes(attribute.Bool("invalid_hash", true))
380 s.pages.Error404(w)
381 return
382 }
383
384 requestURL := fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)
385 span.SetAttributes(attribute.String("request_url", requestURL))
386
387 resp, err := http.Get(requestURL)
388 if err != nil {
389 log.Println("failed to reach knotserver", err)
390 span.RecordError(err)
391 span.SetStatus(codes.Error, "failed to reach knotserver")
392 return
393 }
394
395 body, err := io.ReadAll(resp.Body)
396 if err != nil {
397 log.Printf("Error reading response body: %v", err)
398 span.RecordError(err)
399 span.SetStatus(codes.Error, "error reading response body")
400 return
401 }
402
403 var result types.RepoCommitResponse
404 err = json.Unmarshal(body, &result)
405 if err != nil {
406 log.Println("failed to parse response:", err)
407 span.RecordError(err)
408 span.SetStatus(codes.Error, "failed to parse response")
409 return
410 }
411
412 user := s.auth.GetUser(r)
413 s.pages.RepoCommit(w, pages.RepoCommitParams{
414 LoggedInUser: user,
415 RepoInfo: f.RepoInfo(ctx, s, user),
416 RepoCommitResponse: result,
417 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
418 })
419 return
420}
421
422func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
423 ctx, span := s.t.TraceStart(r.Context(), "RepoTree")
424 defer span.End()
425
426 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
427 if err != nil {
428 log.Println("failed to fully resolve repo", err)
429 span.RecordError(err)
430 span.SetStatus(codes.Error, "failed to fully resolve repo")
431 return
432 }
433
434 ref := chi.URLParam(r, "ref")
435 treePath := chi.URLParam(r, "*")
436 protocol := "http"
437 if !s.config.Dev {
438 protocol = "https"
439 }
440
441 span.SetAttributes(
442 attribute.String("ref", ref),
443 attribute.String("tree_path", treePath),
444 attribute.String("protocol", protocol),
445 )
446
447 requestURL := fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)
448 span.SetAttributes(attribute.String("request_url", requestURL))
449
450 resp, err := http.Get(requestURL)
451 if err != nil {
452 log.Println("failed to reach knotserver", err)
453 span.RecordError(err)
454 span.SetStatus(codes.Error, "failed to reach knotserver")
455 return
456 }
457
458 body, err := io.ReadAll(resp.Body)
459 if err != nil {
460 log.Printf("Error reading response body: %v", err)
461 span.RecordError(err)
462 span.SetStatus(codes.Error, "error reading response body")
463 return
464 }
465
466 var result types.RepoTreeResponse
467 err = json.Unmarshal(body, &result)
468 if err != nil {
469 log.Println("failed to parse response:", err)
470 span.RecordError(err)
471 span.SetStatus(codes.Error, "failed to parse response")
472 return
473 }
474
475 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
476 // so we can safely redirect to the "parent" (which is the same file).
477 if len(result.Files) == 0 && result.Parent == treePath {
478 redirectURL := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent)
479 span.SetAttributes(attribute.String("redirect_url", redirectURL))
480 http.Redirect(w, r, redirectURL, http.StatusFound)
481 return
482 }
483
484 user := s.auth.GetUser(r)
485
486 var breadcrumbs [][]string
487 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
488 if treePath != "" {
489 for idx, elem := range strings.Split(treePath, "/") {
490 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
491 }
492 }
493
494 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
495 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
496
497 s.pages.RepoTree(w, pages.RepoTreeParams{
498 LoggedInUser: user,
499 BreadCrumbs: breadcrumbs,
500 BaseTreeLink: baseTreeLink,
501 BaseBlobLink: baseBlobLink,
502 RepoInfo: f.RepoInfo(ctx, s, user),
503 RepoTreeResponse: result,
504 })
505 return
506}
507
508func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
509 ctx, span := s.t.TraceStart(r.Context(), "RepoTags")
510 defer span.End()
511
512 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
513 if err != nil {
514 log.Println("failed to get repo and knot", err)
515 span.RecordError(err)
516 span.SetStatus(codes.Error, "failed to get repo and knot")
517 return
518 }
519
520 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
521 if err != nil {
522 log.Println("failed to create unsigned client", err)
523 span.RecordError(err)
524 span.SetStatus(codes.Error, "failed to create unsigned client")
525 return
526 }
527
528 result, err := us.Tags(f.OwnerDid(), f.RepoName)
529 if err != nil {
530 log.Println("failed to reach knotserver", err)
531 span.RecordError(err)
532 span.SetStatus(codes.Error, "failed to reach knotserver")
533 return
534 }
535
536 span.SetAttributes(attribute.Int("tags.count", len(result.Tags)))
537
538 artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt))
539 if err != nil {
540 log.Println("failed grab artifacts", err)
541 span.RecordError(err)
542 span.SetStatus(codes.Error, "failed to grab artifacts")
543 return
544 }
545
546 span.SetAttributes(attribute.Int("artifacts.count", len(artifacts)))
547
548 // convert artifacts to map for easy UI building
549 artifactMap := make(map[plumbing.Hash][]db.Artifact)
550 for _, a := range artifacts {
551 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
552 }
553
554 var danglingArtifacts []db.Artifact
555 for _, a := range artifacts {
556 found := false
557 for _, t := range result.Tags {
558 if t.Tag != nil {
559 if t.Tag.Hash == a.Tag {
560 found = true
561 }
562 }
563 }
564
565 if !found {
566 danglingArtifacts = append(danglingArtifacts, a)
567 }
568 }
569
570 span.SetAttributes(attribute.Int("dangling_artifacts.count", len(danglingArtifacts)))
571
572 user := s.auth.GetUser(r)
573 s.pages.RepoTags(w, pages.RepoTagsParams{
574 LoggedInUser: user,
575 RepoInfo: f.RepoInfo(ctx, s, user),
576 RepoTagsResponse: *result,
577 ArtifactMap: artifactMap,
578 DanglingArtifacts: danglingArtifacts,
579 })
580 return
581}
582
583func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
584 ctx, span := s.t.TraceStart(r.Context(), "RepoBranches")
585 defer span.End()
586
587 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
588 if err != nil {
589 log.Println("failed to get repo and knot", err)
590 span.RecordError(err)
591 span.SetStatus(codes.Error, "failed to get repo and knot")
592 return
593 }
594
595 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
596 if err != nil {
597 log.Println("failed to create unsigned client", err)
598 span.RecordError(err)
599 span.SetStatus(codes.Error, "failed to create unsigned client")
600 return
601 }
602
603 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
604 if err != nil {
605 log.Println("failed to reach knotserver", err)
606 span.RecordError(err)
607 span.SetStatus(codes.Error, "failed to reach knotserver")
608 return
609 }
610
611 body, err := io.ReadAll(resp.Body)
612 if err != nil {
613 log.Printf("Error reading response body: %v", err)
614 span.RecordError(err)
615 span.SetStatus(codes.Error, "error reading response body")
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 span.RecordError(err)
624 span.SetStatus(codes.Error, "failed to parse response")
625 return
626 }
627
628 span.SetAttributes(attribute.Int("branches.count", len(result.Branches)))
629
630 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
631 if a.IsDefault {
632 return -1
633 }
634 if b.IsDefault {
635 return 1
636 }
637 if a.Commit != nil {
638 if a.Commit.Author.When.Before(b.Commit.Author.When) {
639 return 1
640 } else {
641 return -1
642 }
643 }
644 return strings.Compare(a.Name, b.Name) * -1
645 })
646
647 user := s.auth.GetUser(r)
648 s.pages.RepoBranches(w, pages.RepoBranchesParams{
649 LoggedInUser: user,
650 RepoInfo: f.RepoInfo(ctx, s, user),
651 RepoBranchesResponse: result,
652 })
653 return
654}
655
656func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
657 ctx, span := s.t.TraceStart(r.Context(), "RepoBlob")
658 defer span.End()
659
660 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
661 if err != nil {
662 log.Println("failed to get repo and knot", err)
663 span.RecordError(err)
664 span.SetStatus(codes.Error, "failed to get repo and knot")
665 return
666 }
667
668 ref := chi.URLParam(r, "ref")
669 filePath := chi.URLParam(r, "*")
670 protocol := "http"
671 if !s.config.Dev {
672 protocol = "https"
673 }
674
675 span.SetAttributes(
676 attribute.String("ref", ref),
677 attribute.String("file_path", filePath),
678 attribute.String("protocol", protocol),
679 )
680
681 requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
682 span.SetAttributes(attribute.String("request_url", requestURL))
683
684 resp, err := http.Get(requestURL)
685 if err != nil {
686 log.Println("failed to reach knotserver", err)
687 span.RecordError(err)
688 span.SetStatus(codes.Error, "failed to reach knotserver")
689 return
690 }
691
692 body, err := io.ReadAll(resp.Body)
693 if err != nil {
694 log.Printf("Error reading response body: %v", err)
695 span.RecordError(err)
696 span.SetStatus(codes.Error, "error reading response body")
697 return
698 }
699
700 var result types.RepoBlobResponse
701 err = json.Unmarshal(body, &result)
702 if err != nil {
703 log.Println("failed to parse response:", err)
704 span.RecordError(err)
705 span.SetStatus(codes.Error, "failed to parse response")
706 return
707 }
708
709 var breadcrumbs [][]string
710 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
711 if filePath != "" {
712 for idx, elem := range strings.Split(filePath, "/") {
713 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
714 }
715 }
716
717 showRendered := false
718 renderToggle := false
719
720 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
721 renderToggle = true
722 showRendered = r.URL.Query().Get("code") != "true"
723 }
724
725 span.SetAttributes(
726 attribute.Bool("is_binary", result.IsBinary),
727 attribute.Bool("show_rendered", showRendered),
728 attribute.Bool("render_toggle", renderToggle),
729 )
730
731 user := s.auth.GetUser(r)
732 s.pages.RepoBlob(w, pages.RepoBlobParams{
733 LoggedInUser: user,
734 RepoInfo: f.RepoInfo(ctx, s, user),
735 RepoBlobResponse: result,
736 BreadCrumbs: breadcrumbs,
737 ShowRendered: showRendered,
738 RenderToggle: renderToggle,
739 })
740 return
741}
742
743func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
744 ctx, span := s.t.TraceStart(r.Context(), "RepoBlobRaw")
745 defer span.End()
746
747 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
748 if err != nil {
749 log.Println("failed to get repo and knot", err)
750 span.RecordError(err)
751 span.SetStatus(codes.Error, "failed to get repo and knot")
752 return
753 }
754
755 ref := chi.URLParam(r, "ref")
756 filePath := chi.URLParam(r, "*")
757
758 protocol := "http"
759 if !s.config.Dev {
760 protocol = "https"
761 }
762
763 span.SetAttributes(
764 attribute.String("ref", ref),
765 attribute.String("file_path", filePath),
766 attribute.String("protocol", protocol),
767 )
768
769 requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
770 span.SetAttributes(attribute.String("request_url", requestURL))
771
772 resp, err := http.Get(requestURL)
773 if err != nil {
774 log.Println("failed to reach knotserver", err)
775 span.RecordError(err)
776 span.SetStatus(codes.Error, "failed to reach knotserver")
777 return
778 }
779
780 body, err := io.ReadAll(resp.Body)
781 if err != nil {
782 log.Printf("Error reading response body: %v", err)
783 span.RecordError(err)
784 span.SetStatus(codes.Error, "error reading response body")
785 return
786 }
787
788 var result types.RepoBlobResponse
789 err = json.Unmarshal(body, &result)
790 if err != nil {
791 log.Println("failed to parse response:", err)
792 span.RecordError(err)
793 span.SetStatus(codes.Error, "failed to parse response")
794 return
795 }
796
797 span.SetAttributes(attribute.Bool("is_binary", result.IsBinary))
798
799 if result.IsBinary {
800 w.Header().Set("Content-Type", "application/octet-stream")
801 w.Write(body)
802 return
803 }
804
805 w.Header().Set("Content-Type", "text/plain")
806 w.Write([]byte(result.Contents))
807 return
808}
809
810func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
811 ctx, span := s.t.TraceStart(r.Context(), "AddCollaborator")
812 defer span.End()
813
814 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
815 if err != nil {
816 log.Println("failed to get repo and knot", err)
817 span.RecordError(err)
818 span.SetStatus(codes.Error, "failed to get repo and knot")
819 return
820 }
821
822 collaborator := r.FormValue("collaborator")
823 if collaborator == "" {
824 span.SetAttributes(attribute.String("error", "malformed_form"))
825 http.Error(w, "malformed form", http.StatusBadRequest)
826 return
827 }
828
829 span.SetAttributes(attribute.String("collaborator", collaborator))
830
831 collaboratorIdent, err := s.resolver.ResolveIdent(ctx, collaborator)
832 if err != nil {
833 span.RecordError(err)
834 span.SetStatus(codes.Error, "failed to resolve collaborator")
835 w.Write([]byte("failed to resolve collaborator did to a handle"))
836 return
837 }
838 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
839 span.SetAttributes(
840 attribute.String("collaborator_did", collaboratorIdent.DID.String()),
841 attribute.String("collaborator_handle", collaboratorIdent.Handle.String()),
842 )
843
844 // TODO: create an atproto record for this
845
846 secret, err := db.GetRegistrationKey(s.db, f.Knot)
847 if err != nil {
848 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
849 span.RecordError(err)
850 span.SetStatus(codes.Error, "no key found for domain")
851 return
852 }
853
854 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
855 if err != nil {
856 log.Println("failed to create client to ", f.Knot)
857 span.RecordError(err)
858 span.SetStatus(codes.Error, "failed to create signed client")
859 return
860 }
861
862 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
863 if err != nil {
864 log.Printf("failed to make request to %s: %s", f.Knot, err)
865 span.RecordError(err)
866 span.SetStatus(codes.Error, "failed to make request to knotserver")
867 return
868 }
869
870 if ksResp.StatusCode != http.StatusNoContent {
871 span.SetAttributes(attribute.Int("status_code", ksResp.StatusCode))
872 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
873 return
874 }
875
876 tx, err := s.db.BeginTx(ctx, nil)
877 if err != nil {
878 log.Println("failed to start tx")
879 span.RecordError(err)
880 span.SetStatus(codes.Error, "failed to start transaction")
881 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
882 return
883 }
884 defer func() {
885 tx.Rollback()
886 err = s.enforcer.E.LoadPolicy()
887 if err != nil {
888 log.Println("failed to rollback policies")
889 }
890 }()
891
892 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
893 if err != nil {
894 span.RecordError(err)
895 span.SetStatus(codes.Error, "failed to add collaborator to enforcer")
896 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
897 return
898 }
899
900 err = db.AddCollaborator(ctx, s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
901 if err != nil {
902 span.RecordError(err)
903 span.SetStatus(codes.Error, "failed to add collaborator to database")
904 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
905 return
906 }
907
908 err = tx.Commit()
909 if err != nil {
910 log.Println("failed to commit changes", err)
911 span.RecordError(err)
912 span.SetStatus(codes.Error, "failed to commit transaction")
913 http.Error(w, err.Error(), http.StatusInternalServerError)
914 return
915 }
916
917 err = s.enforcer.E.SavePolicy()
918 if err != nil {
919 log.Println("failed to update ACLs", err)
920 span.RecordError(err)
921 span.SetStatus(codes.Error, "failed to save enforcer policy")
922 http.Error(w, err.Error(), http.StatusInternalServerError)
923 return
924 }
925
926 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
927}
928
929func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
930 ctx, span := s.t.TraceStart(r.Context(), "DeleteRepo")
931 defer span.End()
932
933 user := s.auth.GetUser(r)
934
935 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
936 if err != nil {
937 log.Println("failed to get repo and knot", err)
938 span.RecordError(err)
939 span.SetStatus(codes.Error, "failed to get repo and knot")
940 return
941 }
942
943 span.SetAttributes(
944 attribute.String("repo_name", f.RepoName),
945 attribute.String("knot", f.Knot),
946 attribute.String("owner_did", f.OwnerDid()),
947 )
948
949 // remove record from pds
950 xrpcClient, _ := s.auth.AuthorizedClient(r)
951 repoRkey := f.RepoAt.RecordKey().String()
952 _, err = comatproto.RepoDeleteRecord(ctx, xrpcClient, &comatproto.RepoDeleteRecord_Input{
953 Collection: tangled.RepoNSID,
954 Repo: user.Did,
955 Rkey: repoRkey,
956 })
957 if err != nil {
958 log.Printf("failed to delete record: %s", err)
959 span.RecordError(err)
960 span.SetStatus(codes.Error, "failed to delete record from PDS")
961 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
962 return
963 }
964 log.Println("removed repo record ", f.RepoAt.String())
965 span.SetAttributes(attribute.String("repo_at", f.RepoAt.String()))
966
967 secret, err := db.GetRegistrationKey(s.db, f.Knot)
968 if err != nil {
969 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
970 span.RecordError(err)
971 span.SetStatus(codes.Error, "no key found for domain")
972 return
973 }
974
975 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
976 if err != nil {
977 log.Println("failed to create client to ", f.Knot)
978 span.RecordError(err)
979 span.SetStatus(codes.Error, "failed to create client")
980 return
981 }
982
983 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
984 if err != nil {
985 log.Printf("failed to make request to %s: %s", f.Knot, err)
986 span.RecordError(err)
987 span.SetStatus(codes.Error, "failed to make request to knotserver")
988 return
989 }
990
991 span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode))
992 if ksResp.StatusCode != http.StatusNoContent {
993 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
994 span.SetAttributes(attribute.Bool("knot_remove_failed", true))
995 } else {
996 log.Println("removed repo from knot ", f.Knot)
997 span.SetAttributes(attribute.Bool("knot_remove_success", true))
998 }
999
1000 tx, err := s.db.BeginTx(ctx, nil)
1001 if err != nil {
1002 log.Println("failed to start tx")
1003 span.RecordError(err)
1004 span.SetStatus(codes.Error, "failed to start transaction")
1005 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
1006 return
1007 }
1008 defer func() {
1009 tx.Rollback()
1010 err = s.enforcer.E.LoadPolicy()
1011 if err != nil {
1012 log.Println("failed to rollback policies")
1013 span.RecordError(err)
1014 }
1015 }()
1016
1017 // remove collaborator RBAC
1018 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1019 if err != nil {
1020 span.RecordError(err)
1021 span.SetStatus(codes.Error, "failed to get collaborators")
1022 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1023 return
1024 }
1025 span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators)))
1026
1027 for _, c := range repoCollaborators {
1028 did := c[0]
1029 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1030 }
1031 log.Println("removed collaborators")
1032
1033 // remove repo RBAC
1034 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1035 if err != nil {
1036 span.RecordError(err)
1037 span.SetStatus(codes.Error, "failed to remove repo RBAC")
1038 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1039 return
1040 }
1041
1042 // remove repo from db
1043 err = db.RemoveRepo(ctx, tx, f.OwnerDid(), f.RepoName)
1044 if err != nil {
1045 span.RecordError(err)
1046 span.SetStatus(codes.Error, "failed to remove repo from db")
1047 s.pages.Notice(w, "settings-delete", "Failed to update appview")
1048 return
1049 }
1050 log.Println("removed repo from db")
1051
1052 err = tx.Commit()
1053 if err != nil {
1054 log.Println("failed to commit changes", err)
1055 span.RecordError(err)
1056 span.SetStatus(codes.Error, "failed to commit transaction")
1057 http.Error(w, err.Error(), http.StatusInternalServerError)
1058 return
1059 }
1060
1061 err = s.enforcer.E.SavePolicy()
1062 if err != nil {
1063 log.Println("failed to update ACLs", err)
1064 span.RecordError(err)
1065 span.SetStatus(codes.Error, "failed to save policy")
1066 http.Error(w, err.Error(), http.StatusInternalServerError)
1067 return
1068 }
1069
1070 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1071}
1072
1073func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1074 ctx, span := s.t.TraceStart(r.Context(), "SetDefaultBranch")
1075 defer span.End()
1076
1077 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1078 if err != nil {
1079 log.Println("failed to get repo and knot", err)
1080 span.RecordError(err)
1081 span.SetStatus(codes.Error, "failed to get repo and knot")
1082 return
1083 }
1084
1085 branch := r.FormValue("branch")
1086 if branch == "" {
1087 span.SetAttributes(attribute.Bool("malformed_form", true))
1088 span.SetStatus(codes.Error, "malformed form")
1089 http.Error(w, "malformed form", http.StatusBadRequest)
1090 return
1091 }
1092
1093 span.SetAttributes(
1094 attribute.String("branch", branch),
1095 attribute.String("repo_name", f.RepoName),
1096 attribute.String("knot", f.Knot),
1097 attribute.String("owner_did", f.OwnerDid()),
1098 )
1099
1100 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1101 if err != nil {
1102 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1103 span.RecordError(err)
1104 span.SetStatus(codes.Error, "no key found for domain")
1105 return
1106 }
1107
1108 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1109 if err != nil {
1110 log.Println("failed to create client to ", f.Knot)
1111 span.RecordError(err)
1112 span.SetStatus(codes.Error, "failed to create client")
1113 return
1114 }
1115
1116 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
1117 if err != nil {
1118 log.Printf("failed to make request to %s: %s", f.Knot, err)
1119 span.RecordError(err)
1120 span.SetStatus(codes.Error, "failed to make request to knotserver")
1121 return
1122 }
1123
1124 span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode))
1125 if ksResp.StatusCode != http.StatusNoContent {
1126 span.SetStatus(codes.Error, "failed to set default branch")
1127 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
1128 return
1129 }
1130
1131 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
1132}
1133
1134func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
1135 ctx, span := s.t.TraceStart(r.Context(), "RepoSettings")
1136 defer span.End()
1137
1138 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1139 if err != nil {
1140 log.Println("failed to get repo and knot", err)
1141 span.RecordError(err)
1142 span.SetStatus(codes.Error, "failed to get repo and knot")
1143 return
1144 }
1145
1146 span.SetAttributes(
1147 attribute.String("repo_name", f.RepoName),
1148 attribute.String("knot", f.Knot),
1149 attribute.String("owner_did", f.OwnerDid()),
1150 attribute.String("method", r.Method),
1151 )
1152
1153 switch r.Method {
1154 case http.MethodGet:
1155 // for now, this is just pubkeys
1156 user := s.auth.GetUser(r)
1157 repoCollaborators, err := f.Collaborators(ctx, s)
1158 if err != nil {
1159 log.Println("failed to get collaborators", err)
1160 span.RecordError(err)
1161 span.SetAttributes(attribute.String("error", "failed_to_get_collaborators"))
1162 }
1163 span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators)))
1164
1165 isCollaboratorInviteAllowed := false
1166 if user != nil {
1167 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1168 if err == nil && ok {
1169 isCollaboratorInviteAllowed = true
1170 }
1171 }
1172 span.SetAttributes(attribute.Bool("invite_allowed", isCollaboratorInviteAllowed))
1173
1174 var branchNames []string
1175 var defaultBranch string
1176 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
1177 if err != nil {
1178 log.Println("failed to create unsigned client", err)
1179 span.RecordError(err)
1180 span.SetAttributes(attribute.String("error", "failed_to_create_unsigned_client"))
1181 } else {
1182 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1183 if err != nil {
1184 log.Println("failed to reach knotserver", err)
1185 span.RecordError(err)
1186 span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_branches"))
1187 } else {
1188 defer resp.Body.Close()
1189
1190 body, err := io.ReadAll(resp.Body)
1191 if err != nil {
1192 log.Printf("Error reading response body: %v", err)
1193 span.RecordError(err)
1194 span.SetAttributes(attribute.String("error", "failed_to_read_branches_response"))
1195 } else {
1196 var result types.RepoBranchesResponse
1197 err = json.Unmarshal(body, &result)
1198 if err != nil {
1199 log.Println("failed to parse response:", err)
1200 span.RecordError(err)
1201 span.SetAttributes(attribute.String("error", "failed_to_parse_branches_response"))
1202 } else {
1203 for _, branch := range result.Branches {
1204 branchNames = append(branchNames, branch.Name)
1205 }
1206 span.SetAttributes(attribute.Int("branches.count", len(branchNames)))
1207 }
1208 }
1209 }
1210
1211 defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName)
1212 if err != nil {
1213 log.Println("failed to reach knotserver", err)
1214 span.RecordError(err)
1215 span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_default_branch"))
1216 } else {
1217 defaultBranch = defaultBranchResp.Branch
1218 span.SetAttributes(attribute.String("default_branch", defaultBranch))
1219 }
1220 }
1221 s.pages.RepoSettings(w, pages.RepoSettingsParams{
1222 LoggedInUser: user,
1223 RepoInfo: f.RepoInfo(ctx, s, user),
1224 Collaborators: repoCollaborators,
1225 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1226 Branches: branchNames,
1227 DefaultBranch: defaultBranch,
1228 })
1229 }
1230}
1231
1232type FullyResolvedRepo struct {
1233 Knot string
1234 OwnerId identity.Identity
1235 RepoName string
1236 RepoAt syntax.ATURI
1237 Description string
1238 CreatedAt string
1239 Ref string
1240}
1241
1242func (f *FullyResolvedRepo) OwnerDid() string {
1243 return f.OwnerId.DID.String()
1244}
1245
1246func (f *FullyResolvedRepo) OwnerHandle() string {
1247 return f.OwnerId.Handle.String()
1248}
1249
1250func (f *FullyResolvedRepo) OwnerSlashRepo() string {
1251 handle := f.OwnerId.Handle
1252
1253 var p string
1254 if handle != "" && !handle.IsInvalidHandle() {
1255 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
1256 } else {
1257 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1258 }
1259
1260 return p
1261}
1262
1263func (f *FullyResolvedRepo) DidSlashRepo() string {
1264 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1265 return p
1266}
1267
1268func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
1269 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1270 if err != nil {
1271 return nil, err
1272 }
1273
1274 var collaborators []pages.Collaborator
1275 for _, item := range repoCollaborators {
1276 // currently only two roles: owner and member
1277 var role string
1278 if item[3] == "repo:owner" {
1279 role = "owner"
1280 } else if item[3] == "repo:collaborator" {
1281 role = "collaborator"
1282 } else {
1283 continue
1284 }
1285
1286 did := item[0]
1287
1288 c := pages.Collaborator{
1289 Did: did,
1290 Handle: "",
1291 Role: role,
1292 }
1293 collaborators = append(collaborators, c)
1294 }
1295
1296 // populate all collborators with handles
1297 identsToResolve := make([]string, len(collaborators))
1298 for i, collab := range collaborators {
1299 identsToResolve[i] = collab.Did
1300 }
1301
1302 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
1303 for i, resolved := range resolvedIdents {
1304 if resolved != nil {
1305 collaborators[i].Handle = resolved.Handle.String()
1306 }
1307 }
1308
1309 return collaborators, nil
1310}
1311
1312func (f *FullyResolvedRepo) RepoInfo(ctx context.Context, s *State, u *auth.User) repoinfo.RepoInfo {
1313 ctx, span := s.t.TraceStart(ctx, "RepoInfo")
1314 defer span.End()
1315
1316 isStarred := false
1317 if u != nil {
1318 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
1319 span.SetAttributes(attribute.Bool("is_starred", isStarred))
1320 }
1321
1322 starCount, err := db.GetStarCount(s.db, f.RepoAt)
1323 if err != nil {
1324 log.Println("failed to get star count for ", f.RepoAt)
1325 span.RecordError(err)
1326 }
1327
1328 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
1329 if err != nil {
1330 log.Println("failed to get issue count for ", f.RepoAt)
1331 span.RecordError(err)
1332 }
1333
1334 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
1335 if err != nil {
1336 log.Println("failed to get issue count for ", f.RepoAt)
1337 span.RecordError(err)
1338 }
1339
1340 span.SetAttributes(
1341 attribute.Int("stats.stars", starCount),
1342 attribute.Int("stats.issues.open", issueCount.Open),
1343 attribute.Int("stats.issues.closed", issueCount.Closed),
1344 attribute.Int("stats.pulls.open", pullCount.Open),
1345 attribute.Int("stats.pulls.closed", pullCount.Closed),
1346 attribute.Int("stats.pulls.merged", pullCount.Merged),
1347 )
1348
1349 source, err := db.GetRepoSource(ctx, s.db, f.RepoAt)
1350 if errors.Is(err, sql.ErrNoRows) {
1351 source = ""
1352 } else if err != nil {
1353 log.Println("failed to get repo source for ", f.RepoAt, err)
1354 span.RecordError(err)
1355 }
1356
1357 var sourceRepo *db.Repo
1358 if source != "" {
1359 span.SetAttributes(attribute.String("source", source))
1360 sourceRepo, err = db.GetRepoByAtUri(ctx, s.db, source)
1361 if err != nil {
1362 log.Println("failed to get repo by at uri", err)
1363 span.RecordError(err)
1364 }
1365 }
1366
1367 var sourceHandle *identity.Identity
1368 if sourceRepo != nil {
1369 sourceHandle, err = s.resolver.ResolveIdent(ctx, sourceRepo.Did)
1370 if err != nil {
1371 log.Println("failed to resolve source repo", err)
1372 span.RecordError(err)
1373 } else if sourceHandle != nil {
1374 span.SetAttributes(attribute.String("source_handle", sourceHandle.Handle.String()))
1375 }
1376 }
1377
1378 knot := f.Knot
1379 span.SetAttributes(attribute.String("knot", knot))
1380
1381 var disableFork bool
1382 us, err := NewUnsignedClient(knot, s.config.Dev)
1383 if err != nil {
1384 log.Printf("failed to create unsigned client for %s: %v", knot, err)
1385 span.RecordError(err)
1386 } else {
1387 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1388 if err != nil {
1389 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1390 span.RecordError(err)
1391 } else {
1392 defer resp.Body.Close()
1393 body, err := io.ReadAll(resp.Body)
1394 if err != nil {
1395 log.Printf("error reading branch response body: %v", err)
1396 span.RecordError(err)
1397 } else {
1398 var branchesResp types.RepoBranchesResponse
1399 if err := json.Unmarshal(body, &branchesResp); err != nil {
1400 log.Printf("error parsing branch response: %v", err)
1401 span.RecordError(err)
1402 } else {
1403 disableFork = false
1404 }
1405
1406 if len(branchesResp.Branches) == 0 {
1407 disableFork = true
1408 }
1409 span.SetAttributes(
1410 attribute.Int("branches.count", len(branchesResp.Branches)),
1411 attribute.Bool("disable_fork", disableFork),
1412 )
1413 }
1414 }
1415 }
1416
1417 repoInfo := repoinfo.RepoInfo{
1418 OwnerDid: f.OwnerDid(),
1419 OwnerHandle: f.OwnerHandle(),
1420 Name: f.RepoName,
1421 RepoAt: f.RepoAt,
1422 Description: f.Description,
1423 Ref: f.Ref,
1424 IsStarred: isStarred,
1425 Knot: knot,
1426 Roles: RolesInRepo(s, u, f),
1427 Stats: db.RepoStats{
1428 StarCount: starCount,
1429 IssueCount: issueCount,
1430 PullCount: pullCount,
1431 },
1432 DisableFork: disableFork,
1433 }
1434
1435 if sourceRepo != nil {
1436 repoInfo.Source = sourceRepo
1437 repoInfo.SourceHandle = sourceHandle.Handle.String()
1438 }
1439
1440 return repoInfo
1441}
1442
1443func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1444 ctx, span := s.t.TraceStart(r.Context(), "RepoSingleIssue")
1445 defer span.End()
1446
1447 user := s.auth.GetUser(r)
1448 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1449 if err != nil {
1450 log.Println("failed to get repo and knot", err)
1451 span.RecordError(err)
1452 span.SetStatus(codes.Error, "failed to resolve repo")
1453 return
1454 }
1455
1456 issueId := chi.URLParam(r, "issue")
1457 issueIdInt, err := strconv.Atoi(issueId)
1458 if err != nil {
1459 http.Error(w, "bad issue id", http.StatusBadRequest)
1460 log.Println("failed to parse issue id", err)
1461 span.RecordError(err)
1462 span.SetStatus(codes.Error, "failed to parse issue id")
1463 return
1464 }
1465
1466 span.SetAttributes(attribute.Int("issue_id", issueIdInt))
1467
1468 issue, comments, err := db.GetIssueWithComments(ctx, s.db, f.RepoAt, issueIdInt)
1469 if err != nil {
1470 log.Println("failed to get issue and comments", err)
1471 span.RecordError(err)
1472 span.SetStatus(codes.Error, "failed to get issue and comments")
1473 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1474 return
1475 }
1476
1477 span.SetAttributes(
1478 attribute.Int("comments.count", len(comments)),
1479 attribute.String("issue.title", issue.Title),
1480 attribute.String("issue.owner_did", issue.OwnerDid),
1481 )
1482
1483 issueOwnerIdent, err := s.resolver.ResolveIdent(ctx, issue.OwnerDid)
1484 if err != nil {
1485 log.Println("failed to resolve issue owner", err)
1486 span.RecordError(err)
1487 span.SetStatus(codes.Error, "failed to resolve issue owner")
1488 }
1489
1490 identsToResolve := make([]string, len(comments))
1491 for i, comment := range comments {
1492 identsToResolve[i] = comment.OwnerDid
1493 }
1494 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
1495 didHandleMap := make(map[string]string)
1496 for _, identity := range resolvedIds {
1497 if !identity.Handle.IsInvalidHandle() {
1498 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1499 } else {
1500 didHandleMap[identity.DID.String()] = identity.DID.String()
1501 }
1502 }
1503
1504 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1505 LoggedInUser: user,
1506 RepoInfo: f.RepoInfo(ctx, s, user),
1507 Issue: *issue,
1508 Comments: comments,
1509
1510 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1511 DidHandleMap: didHandleMap,
1512 })
1513}
1514
1515func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1516 ctx, span := s.t.TraceStart(r.Context(), "CloseIssue")
1517 defer span.End()
1518
1519 user := s.auth.GetUser(r)
1520 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1521 if err != nil {
1522 log.Println("failed to get repo and knot", err)
1523 span.RecordError(err)
1524 span.SetStatus(codes.Error, "failed to resolve repo")
1525 return
1526 }
1527
1528 issueId := chi.URLParam(r, "issue")
1529 issueIdInt, err := strconv.Atoi(issueId)
1530 if err != nil {
1531 http.Error(w, "bad issue id", http.StatusBadRequest)
1532 log.Println("failed to parse issue id", err)
1533 span.RecordError(err)
1534 span.SetStatus(codes.Error, "failed to parse issue id")
1535 return
1536 }
1537
1538 span.SetAttributes(attribute.Int("issue_id", issueIdInt))
1539
1540 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt)
1541 if err != nil {
1542 log.Println("failed to get issue", err)
1543 span.RecordError(err)
1544 span.SetStatus(codes.Error, "failed to get issue")
1545 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1546 return
1547 }
1548
1549 collaborators, err := f.Collaborators(ctx, s)
1550 if err != nil {
1551 log.Println("failed to fetch repo collaborators: %w", err)
1552 span.RecordError(err)
1553 span.SetStatus(codes.Error, "failed to fetch repo collaborators")
1554 }
1555 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1556 return user.Did == collab.Did
1557 })
1558 isIssueOwner := user.Did == issue.OwnerDid
1559
1560 span.SetAttributes(
1561 attribute.Bool("is_collaborator", isCollaborator),
1562 attribute.Bool("is_issue_owner", isIssueOwner),
1563 )
1564
1565 // TODO: make this more granular
1566 if isIssueOwner || isCollaborator {
1567 closed := tangled.RepoIssueStateClosed
1568
1569 client, _ := s.auth.AuthorizedClient(r)
1570 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1571 Collection: tangled.RepoIssueStateNSID,
1572 Repo: user.Did,
1573 Rkey: appview.TID(),
1574 Record: &lexutil.LexiconTypeDecoder{
1575 Val: &tangled.RepoIssueState{
1576 Issue: issue.IssueAt,
1577 State: closed,
1578 },
1579 },
1580 })
1581
1582 if err != nil {
1583 log.Println("failed to update issue state", err)
1584 span.RecordError(err)
1585 span.SetStatus(codes.Error, "failed to update issue state in PDS")
1586 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1587 return
1588 }
1589
1590 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1591 if err != nil {
1592 log.Println("failed to close issue", err)
1593 span.RecordError(err)
1594 span.SetStatus(codes.Error, "failed to close issue in database")
1595 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1596 return
1597 }
1598
1599 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1600 return
1601 } else {
1602 log.Println("user is not permitted to close issue")
1603 span.SetAttributes(attribute.Bool("permission_denied", true))
1604 http.Error(w, "for biden", http.StatusUnauthorized)
1605 return
1606 }
1607}
1608
1609func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1610 ctx, span := s.t.TraceStart(r.Context(), "ReopenIssue")
1611 defer span.End()
1612
1613 user := s.auth.GetUser(r)
1614 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1615 if err != nil {
1616 log.Println("failed to get repo and knot", err)
1617 span.RecordError(err)
1618 span.SetStatus(codes.Error, "failed to resolve repo")
1619 return
1620 }
1621
1622 issueId := chi.URLParam(r, "issue")
1623 issueIdInt, err := strconv.Atoi(issueId)
1624 if err != nil {
1625 http.Error(w, "bad issue id", http.StatusBadRequest)
1626 log.Println("failed to parse issue id", err)
1627 span.RecordError(err)
1628 span.SetStatus(codes.Error, "failed to parse issue id")
1629 return
1630 }
1631
1632 span.SetAttributes(attribute.Int("issue_id", issueIdInt))
1633
1634 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt)
1635 if err != nil {
1636 log.Println("failed to get issue", err)
1637 span.RecordError(err)
1638 span.SetStatus(codes.Error, "failed to get issue")
1639 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1640 return
1641 }
1642
1643 collaborators, err := f.Collaborators(ctx, s)
1644 if err != nil {
1645 log.Println("failed to fetch repo collaborators: %w", err)
1646 span.RecordError(err)
1647 span.SetStatus(codes.Error, "failed to fetch repo collaborators")
1648 }
1649 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1650 return user.Did == collab.Did
1651 })
1652 isIssueOwner := user.Did == issue.OwnerDid
1653
1654 span.SetAttributes(
1655 attribute.Bool("is_collaborator", isCollaborator),
1656 attribute.Bool("is_issue_owner", isIssueOwner),
1657 )
1658
1659 if isCollaborator || isIssueOwner {
1660 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1661 if err != nil {
1662 log.Println("failed to reopen issue", err)
1663 span.RecordError(err)
1664 span.SetStatus(codes.Error, "failed to reopen issue")
1665 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1666 return
1667 }
1668 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1669 return
1670 } else {
1671 log.Println("user is not the owner of the repo")
1672 span.SetAttributes(attribute.Bool("permission_denied", true))
1673 http.Error(w, "forbidden", http.StatusUnauthorized)
1674 return
1675 }
1676}
1677
1678func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1679 ctx, span := s.t.TraceStart(r.Context(), "NewIssueComment")
1680 defer span.End()
1681
1682 user := s.auth.GetUser(r)
1683 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1684 if err != nil {
1685 log.Println("failed to get repo and knot", err)
1686 span.RecordError(err)
1687 span.SetStatus(codes.Error, "failed to resolve repo")
1688 return
1689 }
1690
1691 issueId := chi.URLParam(r, "issue")
1692 issueIdInt, err := strconv.Atoi(issueId)
1693 if err != nil {
1694 http.Error(w, "bad issue id", http.StatusBadRequest)
1695 log.Println("failed to parse issue id", err)
1696 span.RecordError(err)
1697 span.SetStatus(codes.Error, "failed to parse issue id")
1698 return
1699 }
1700
1701 span.SetAttributes(
1702 attribute.Int("issue_id", issueIdInt),
1703 attribute.String("method", r.Method),
1704 )
1705
1706 switch r.Method {
1707 case http.MethodPost:
1708 body := r.FormValue("body")
1709 if body == "" {
1710 span.SetAttributes(attribute.Bool("missing_body", true))
1711 s.pages.Notice(w, "issue", "Body is required")
1712 return
1713 }
1714
1715 commentId := mathrand.IntN(1000000)
1716 rkey := appview.TID()
1717
1718 span.SetAttributes(
1719 attribute.Int("comment_id", commentId),
1720 attribute.String("rkey", rkey),
1721 )
1722
1723 err := db.NewIssueComment(s.db, &db.Comment{
1724 OwnerDid: user.Did,
1725 RepoAt: f.RepoAt,
1726 Issue: issueIdInt,
1727 CommentId: commentId,
1728 Body: body,
1729 Rkey: rkey,
1730 })
1731 if err != nil {
1732 log.Println("failed to create comment", err)
1733 span.RecordError(err)
1734 span.SetStatus(codes.Error, "failed to create comment in database")
1735 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1736 return
1737 }
1738
1739 createdAt := time.Now().Format(time.RFC3339)
1740 commentIdInt64 := int64(commentId)
1741 ownerDid := user.Did
1742 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1743 if err != nil {
1744 log.Println("failed to get issue at", err)
1745 span.RecordError(err)
1746 span.SetStatus(codes.Error, "failed to get issue at")
1747 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1748 return
1749 }
1750
1751 span.SetAttributes(attribute.String("issue_at", issueAt))
1752
1753 atUri := f.RepoAt.String()
1754 client, _ := s.auth.AuthorizedClient(r)
1755 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1756 Collection: tangled.RepoIssueCommentNSID,
1757 Repo: user.Did,
1758 Rkey: rkey,
1759 Record: &lexutil.LexiconTypeDecoder{
1760 Val: &tangled.RepoIssueComment{
1761 Repo: &atUri,
1762 Issue: issueAt,
1763 CommentId: &commentIdInt64,
1764 Owner: &ownerDid,
1765 Body: body,
1766 CreatedAt: createdAt,
1767 },
1768 },
1769 })
1770 if err != nil {
1771 log.Println("failed to create comment", err)
1772 span.RecordError(err)
1773 span.SetStatus(codes.Error, "failed to create comment in PDS")
1774 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1775 return
1776 }
1777
1778 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1779 return
1780 }
1781}
1782
1783func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1784 ctx, span := s.t.TraceStart(r.Context(), "IssueComment")
1785 defer span.End()
1786
1787 user := s.auth.GetUser(r)
1788 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1789 if err != nil {
1790 log.Println("failed to get repo and knot", err)
1791 span.RecordError(err)
1792 span.SetStatus(codes.Error, "failed to resolve repo")
1793 return
1794 }
1795
1796 issueId := chi.URLParam(r, "issue")
1797 issueIdInt, err := strconv.Atoi(issueId)
1798 if err != nil {
1799 http.Error(w, "bad issue id", http.StatusBadRequest)
1800 log.Println("failed to parse issue id", err)
1801 span.RecordError(err)
1802 span.SetStatus(codes.Error, "failed to parse issue id")
1803 return
1804 }
1805
1806 commentId := chi.URLParam(r, "comment_id")
1807 commentIdInt, err := strconv.Atoi(commentId)
1808 if err != nil {
1809 http.Error(w, "bad comment id", http.StatusBadRequest)
1810 log.Println("failed to parse issue id", err)
1811 span.RecordError(err)
1812 span.SetStatus(codes.Error, "failed to parse comment id")
1813 return
1814 }
1815
1816 span.SetAttributes(
1817 attribute.Int("issue_id", issueIdInt),
1818 attribute.Int("comment_id", commentIdInt),
1819 )
1820
1821 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt)
1822 if err != nil {
1823 log.Println("failed to get issue", err)
1824 span.RecordError(err)
1825 span.SetStatus(codes.Error, "failed to get issue")
1826 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1827 return
1828 }
1829
1830 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1831 if err != nil {
1832 http.Error(w, "bad comment id", http.StatusBadRequest)
1833 span.RecordError(err)
1834 span.SetStatus(codes.Error, "failed to get comment")
1835 return
1836 }
1837
1838 identity, err := s.resolver.ResolveIdent(ctx, comment.OwnerDid)
1839 if err != nil {
1840 log.Println("failed to resolve did")
1841 span.RecordError(err)
1842 span.SetStatus(codes.Error, "failed to resolve did")
1843 return
1844 }
1845
1846 didHandleMap := make(map[string]string)
1847 if !identity.Handle.IsInvalidHandle() {
1848 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1849 } else {
1850 didHandleMap[identity.DID.String()] = identity.DID.String()
1851 }
1852
1853 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1854 LoggedInUser: user,
1855 RepoInfo: f.RepoInfo(ctx, s, user),
1856 DidHandleMap: didHandleMap,
1857 Issue: issue,
1858 Comment: comment,
1859 })
1860}
1861
1862func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1863 ctx, span := s.t.TraceStart(r.Context(), "EditIssueComment")
1864 defer span.End()
1865
1866 user := s.auth.GetUser(r)
1867 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1868 if err != nil {
1869 log.Println("failed to get repo and knot", err)
1870 span.RecordError(err)
1871 span.SetStatus(codes.Error, "failed to resolve repo")
1872 return
1873 }
1874
1875 issueId := chi.URLParam(r, "issue")
1876 issueIdInt, err := strconv.Atoi(issueId)
1877 if err != nil {
1878 http.Error(w, "bad issue id", http.StatusBadRequest)
1879 log.Println("failed to parse issue id", err)
1880 span.RecordError(err)
1881 span.SetStatus(codes.Error, "failed to parse issue id")
1882 return
1883 }
1884
1885 commentId := chi.URLParam(r, "comment_id")
1886 commentIdInt, err := strconv.Atoi(commentId)
1887 if err != nil {
1888 http.Error(w, "bad comment id", http.StatusBadRequest)
1889 log.Println("failed to parse issue id", err)
1890 span.RecordError(err)
1891 span.SetStatus(codes.Error, "failed to parse comment id")
1892 return
1893 }
1894
1895 span.SetAttributes(
1896 attribute.Int("issue_id", issueIdInt),
1897 attribute.Int("comment_id", commentIdInt),
1898 attribute.String("method", r.Method),
1899 )
1900
1901 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt)
1902 if err != nil {
1903 log.Println("failed to get issue", err)
1904 span.RecordError(err)
1905 span.SetStatus(codes.Error, "failed to get issue")
1906 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1907 return
1908 }
1909
1910 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1911 if err != nil {
1912 http.Error(w, "bad comment id", http.StatusBadRequest)
1913 span.RecordError(err)
1914 span.SetStatus(codes.Error, "failed to get comment")
1915 return
1916 }
1917
1918 if comment.OwnerDid != user.Did {
1919 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1920 span.SetAttributes(attribute.Bool("permission_denied", true))
1921 return
1922 }
1923
1924 switch r.Method {
1925 case http.MethodGet:
1926 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1927 LoggedInUser: user,
1928 RepoInfo: f.RepoInfo(ctx, s, user),
1929 Issue: issue,
1930 Comment: comment,
1931 })
1932 case http.MethodPost:
1933 // extract form value
1934 newBody := r.FormValue("body")
1935 client, _ := s.auth.AuthorizedClient(r)
1936 rkey := comment.Rkey
1937
1938 span.SetAttributes(
1939 attribute.String("new_body", newBody),
1940 attribute.String("rkey", rkey),
1941 )
1942
1943 // optimistic update
1944 edited := time.Now()
1945 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1946 if err != nil {
1947 log.Println("failed to perferom update-description query", err)
1948 span.RecordError(err)
1949 span.SetStatus(codes.Error, "failed to edit comment in database")
1950 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1951 return
1952 }
1953
1954 // rkey is optional, it was introduced later
1955 if comment.Rkey != "" {
1956 // update the record on pds
1957 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1958 if err != nil {
1959 // failed to get record
1960 log.Println(err, rkey)
1961 span.RecordError(err)
1962 span.SetStatus(codes.Error, "failed to get record from PDS")
1963 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1964 return
1965 }
1966 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1967 record, _ := data.UnmarshalJSON(value)
1968
1969 repoAt := record["repo"].(string)
1970 issueAt := record["issue"].(string)
1971 createdAt := record["createdAt"].(string)
1972 commentIdInt64 := int64(commentIdInt)
1973
1974 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1975 Collection: tangled.RepoIssueCommentNSID,
1976 Repo: user.Did,
1977 Rkey: rkey,
1978 SwapRecord: ex.Cid,
1979 Record: &lexutil.LexiconTypeDecoder{
1980 Val: &tangled.RepoIssueComment{
1981 Repo: &repoAt,
1982 Issue: issueAt,
1983 CommentId: &commentIdInt64,
1984 Owner: &comment.OwnerDid,
1985 Body: newBody,
1986 CreatedAt: createdAt,
1987 },
1988 },
1989 })
1990 if err != nil {
1991 log.Println(err)
1992 span.RecordError(err)
1993 span.SetStatus(codes.Error, "failed to put record to PDS")
1994 }
1995 }
1996
1997 // optimistic update for htmx
1998 didHandleMap := map[string]string{
1999 user.Did: user.Handle,
2000 }
2001 comment.Body = newBody
2002 comment.Edited = &edited
2003
2004 // return new comment body with htmx
2005 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
2006 LoggedInUser: user,
2007 RepoInfo: f.RepoInfo(ctx, s, user),
2008 DidHandleMap: didHandleMap,
2009 Issue: issue,
2010 Comment: comment,
2011 })
2012 return
2013 }
2014}
2015
2016func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
2017 ctx, span := s.t.TraceStart(r.Context(), "DeleteIssueComment")
2018 defer span.End()
2019
2020 user := s.auth.GetUser(r)
2021 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
2022 if err != nil {
2023 log.Println("failed to get repo and knot", err)
2024 span.RecordError(err)
2025 span.SetStatus(codes.Error, "failed to resolve repo")
2026 return
2027 }
2028
2029 issueId := chi.URLParam(r, "issue")
2030 issueIdInt, err := strconv.Atoi(issueId)
2031 if err != nil {
2032 http.Error(w, "bad issue id", http.StatusBadRequest)
2033 log.Println("failed to parse issue id", err)
2034 span.RecordError(err)
2035 span.SetStatus(codes.Error, "failed to parse issue id")
2036 return
2037 }
2038
2039 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt)
2040 if err != nil {
2041 log.Println("failed to get issue", err)
2042 span.RecordError(err)
2043 span.SetStatus(codes.Error, "failed to get issue")
2044 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
2045 return
2046 }
2047
2048 commentId := chi.URLParam(r, "comment_id")
2049 commentIdInt, err := strconv.Atoi(commentId)
2050 if err != nil {
2051 http.Error(w, "bad comment id", http.StatusBadRequest)
2052 log.Println("failed to parse issue id", err)
2053 span.RecordError(err)
2054 span.SetStatus(codes.Error, "failed to parse comment id")
2055 return
2056 }
2057
2058 span.SetAttributes(
2059 attribute.Int("issue_id", issueIdInt),
2060 attribute.Int("comment_id", commentIdInt),
2061 )
2062
2063 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
2064 if err != nil {
2065 http.Error(w, "bad comment id", http.StatusBadRequest)
2066 span.RecordError(err)
2067 span.SetStatus(codes.Error, "failed to get comment")
2068 return
2069 }
2070
2071 if comment.OwnerDid != user.Did {
2072 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
2073 span.SetAttributes(attribute.Bool("permission_denied", true))
2074 return
2075 }
2076
2077 if comment.Deleted != nil {
2078 http.Error(w, "comment already deleted", http.StatusBadRequest)
2079 span.SetAttributes(attribute.Bool("already_deleted", true))
2080 return
2081 }
2082
2083 // optimistic deletion
2084 deleted := time.Now()
2085 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
2086 if err != nil {
2087 log.Println("failed to delete comment")
2088 span.RecordError(err)
2089 span.SetStatus(codes.Error, "failed to delete comment in database")
2090 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
2091 return
2092 }
2093
2094 // delete from pds
2095 if comment.Rkey != "" {
2096 client, _ := s.auth.AuthorizedClient(r)
2097 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2098 Collection: tangled.GraphFollowNSID,
2099 Repo: user.Did,
2100 Rkey: comment.Rkey,
2101 })
2102 if err != nil {
2103 log.Println(err)
2104 span.RecordError(err)
2105 span.SetStatus(codes.Error, "failed to delete record from PDS")
2106 }
2107 }
2108
2109 // optimistic update for htmx
2110 didHandleMap := map[string]string{
2111 user.Did: user.Handle,
2112 }
2113 comment.Body = ""
2114 comment.Deleted = &deleted
2115
2116 // htmx fragment of comment after deletion
2117 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
2118 LoggedInUser: user,
2119 RepoInfo: f.RepoInfo(ctx, s, user),
2120 DidHandleMap: didHandleMap,
2121 Issue: issue,
2122 Comment: comment,
2123 })
2124 return
2125}
2126
2127func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
2128 ctx, span := s.t.TraceStart(r.Context(), "RepoIssues")
2129 defer span.End()
2130
2131 params := r.URL.Query()
2132 state := params.Get("state")
2133 isOpen := true
2134 switch state {
2135 case "open":
2136 isOpen = true
2137 case "closed":
2138 isOpen = false
2139 default:
2140 isOpen = true
2141 }
2142
2143 span.SetAttributes(
2144 attribute.Bool("is_open", isOpen),
2145 attribute.String("state_param", state),
2146 )
2147
2148 page, ok := r.Context().Value("page").(pagination.Page)
2149 if !ok {
2150 log.Println("failed to get page")
2151 span.SetAttributes(attribute.Bool("page_not_found", true))
2152 page = pagination.FirstPage()
2153 }
2154
2155 user := s.auth.GetUser(r)
2156 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
2157 if err != nil {
2158 log.Println("failed to get repo and knot", err)
2159 span.RecordError(err)
2160 span.SetStatus(codes.Error, "failed to resolve repo")
2161 return
2162 }
2163
2164 issues, err := db.GetIssues(ctx, s.db, f.RepoAt, isOpen, page)
2165 if err != nil {
2166 log.Println("failed to get issues", err)
2167 span.RecordError(err)
2168 span.SetStatus(codes.Error, "failed to get issues")
2169 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
2170 return
2171 }
2172
2173 span.SetAttributes(attribute.Int("issues.count", len(issues)))
2174
2175 identsToResolve := make([]string, len(issues))
2176 for i, issue := range issues {
2177 identsToResolve[i] = issue.OwnerDid
2178 }
2179 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
2180 didHandleMap := make(map[string]string)
2181 for _, identity := range resolvedIds {
2182 if !identity.Handle.IsInvalidHandle() {
2183 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
2184 } else {
2185 didHandleMap[identity.DID.String()] = identity.DID.String()
2186 }
2187 }
2188
2189 s.pages.RepoIssues(w, pages.RepoIssuesParams{
2190 LoggedInUser: s.auth.GetUser(r),
2191 RepoInfo: f.RepoInfo(ctx, s, user),
2192 Issues: issues,
2193 DidHandleMap: didHandleMap,
2194 FilteringByOpen: isOpen,
2195 Page: page,
2196 })
2197 return
2198}
2199
2200func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
2201 ctx, span := s.t.TraceStart(r.Context(), "NewIssue")
2202 defer span.End()
2203
2204 user := s.auth.GetUser(r)
2205
2206 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
2207 if err != nil {
2208 log.Println("failed to get repo and knot", err)
2209 span.RecordError(err)
2210 span.SetStatus(codes.Error, "failed to resolve repo")
2211 return
2212 }
2213
2214 span.SetAttributes(attribute.String("method", r.Method))
2215
2216 switch r.Method {
2217 case http.MethodGet:
2218 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
2219 LoggedInUser: user,
2220 RepoInfo: f.RepoInfo(ctx, s, user),
2221 })
2222 case http.MethodPost:
2223 title := r.FormValue("title")
2224 body := r.FormValue("body")
2225
2226 span.SetAttributes(
2227 attribute.String("title", title),
2228 attribute.String("body_length", fmt.Sprintf("%d", len(body))),
2229 )
2230
2231 if title == "" || body == "" {
2232 span.SetAttributes(attribute.Bool("form_validation_failed", true))
2233 s.pages.Notice(w, "issues", "Title and body are required")
2234 return
2235 }
2236
2237 tx, err := s.db.BeginTx(ctx, nil)
2238 if err != nil {
2239 span.RecordError(err)
2240 span.SetStatus(codes.Error, "failed to begin transaction")
2241 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
2242 return
2243 }
2244
2245 err = db.NewIssue(tx, &db.Issue{
2246 RepoAt: f.RepoAt,
2247 Title: title,
2248 Body: body,
2249 OwnerDid: user.Did,
2250 })
2251 if err != nil {
2252 log.Println("failed to create issue", err)
2253 span.RecordError(err)
2254 span.SetStatus(codes.Error, "failed to create issue in database")
2255 s.pages.Notice(w, "issues", "Failed to create issue.")
2256 return
2257 }
2258
2259 issueId, err := db.GetIssueId(s.db, f.RepoAt)
2260 if err != nil {
2261 log.Println("failed to get issue id", err)
2262 span.RecordError(err)
2263 span.SetStatus(codes.Error, "failed to get issue id")
2264 s.pages.Notice(w, "issues", "Failed to create issue.")
2265 return
2266 }
2267
2268 span.SetAttributes(attribute.Int("issue_id", issueId))
2269
2270 client, _ := s.auth.AuthorizedClient(r)
2271 atUri := f.RepoAt.String()
2272 rkey := appview.TID()
2273 span.SetAttributes(attribute.String("rkey", rkey))
2274
2275 resp, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
2276 Collection: tangled.RepoIssueNSID,
2277 Repo: user.Did,
2278 Rkey: rkey,
2279 Record: &lexutil.LexiconTypeDecoder{
2280 Val: &tangled.RepoIssue{
2281 Repo: atUri,
2282 Title: title,
2283 Body: &body,
2284 Owner: user.Did,
2285 IssueId: int64(issueId),
2286 },
2287 },
2288 })
2289 if err != nil {
2290 log.Println("failed to create issue", err)
2291 span.RecordError(err)
2292 span.SetStatus(codes.Error, "failed to create issue in PDS")
2293 s.pages.Notice(w, "issues", "Failed to create issue.")
2294 return
2295 }
2296
2297 span.SetAttributes(attribute.String("issue_uri", resp.Uri))
2298
2299 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
2300 if err != nil {
2301 log.Println("failed to set issue at", err)
2302 span.RecordError(err)
2303 span.SetStatus(codes.Error, "failed to set issue URI in database")
2304 s.pages.Notice(w, "issues", "Failed to create issue.")
2305 return
2306 }
2307
2308 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
2309 return
2310 }
2311}
2312
2313func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
2314 ctx, span := s.t.TraceStart(r.Context(), "ForkRepo")
2315 defer span.End()
2316
2317 user := s.auth.GetUser(r)
2318 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
2319 if err != nil {
2320 log.Printf("failed to resolve source repo: %v", err)
2321 span.RecordError(err)
2322 span.SetStatus(codes.Error, "failed to resolve source repo")
2323 return
2324 }
2325
2326 span.SetAttributes(
2327 attribute.String("method", r.Method),
2328 attribute.String("repo_name", f.RepoName),
2329 attribute.String("owner_did", f.OwnerDid()),
2330 attribute.String("knot", f.Knot),
2331 )
2332
2333 switch r.Method {
2334 case http.MethodGet:
2335 user := s.auth.GetUser(r)
2336 knots, err := s.enforcer.GetDomainsForUser(user.Did)
2337 if err != nil {
2338 span.RecordError(err)
2339 span.SetStatus(codes.Error, "failed to get domains for user")
2340 s.pages.Notice(w, "repo", "Invalid user account.")
2341 return
2342 }
2343
2344 span.SetAttributes(attribute.Int("knots.count", len(knots)))
2345
2346 s.pages.ForkRepo(w, pages.ForkRepoParams{
2347 LoggedInUser: user,
2348 Knots: knots,
2349 RepoInfo: f.RepoInfo(ctx, s, user),
2350 })
2351
2352 case http.MethodPost:
2353 knot := r.FormValue("knot")
2354 if knot == "" {
2355 span.SetAttributes(attribute.Bool("missing_knot", true))
2356 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
2357 return
2358 }
2359
2360 span.SetAttributes(attribute.String("target_knot", knot))
2361
2362 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
2363 if err != nil || !ok {
2364 span.SetAttributes(
2365 attribute.Bool("permission_denied", true),
2366 attribute.Bool("enforce_error", err != nil),
2367 )
2368 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
2369 return
2370 }
2371
2372 forkName := fmt.Sprintf("%s", f.RepoName)
2373 span.SetAttributes(attribute.String("fork_name", forkName))
2374
2375 // this check is *only* to see if the forked repo name already exists
2376 // in the user's account.
2377 existingRepo, err := db.GetRepo(ctx, s.db, user.Did, f.RepoName)
2378 if err != nil {
2379 if errors.Is(err, sql.ErrNoRows) {
2380 // no existing repo with this name found, we can use the name as is
2381 span.SetAttributes(attribute.Bool("repo_name_available", true))
2382 } else {
2383 log.Println("error fetching existing repo from db", err)
2384 span.RecordError(err)
2385 span.SetStatus(codes.Error, "failed to check for existing repo")
2386 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2387 return
2388 }
2389 } else if existingRepo != nil {
2390 // repo with this name already exists, append random string
2391 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2392 span.SetAttributes(
2393 attribute.Bool("repo_name_conflict", true),
2394 attribute.String("adjusted_fork_name", forkName),
2395 )
2396 }
2397
2398 secret, err := db.GetRegistrationKey(s.db, knot)
2399 if err != nil {
2400 span.RecordError(err)
2401 span.SetStatus(codes.Error, "failed to get registration key")
2402 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
2403 return
2404 }
2405
2406 client, err := NewSignedClient(knot, secret, s.config.Dev)
2407 if err != nil {
2408 span.RecordError(err)
2409 span.SetStatus(codes.Error, "failed to create signed client")
2410 s.pages.Notice(w, "repo", "Failed to reach knot server.")
2411 return
2412 }
2413
2414 var uri string
2415 if s.config.Dev {
2416 uri = "http"
2417 } else {
2418 uri = "https"
2419 }
2420 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
2421 sourceAt := f.RepoAt.String()
2422
2423 span.SetAttributes(
2424 attribute.String("fork_source_url", forkSourceUrl),
2425 attribute.String("source_at", sourceAt),
2426 )
2427
2428 rkey := appview.TID()
2429 repo := &db.Repo{
2430 Did: user.Did,
2431 Name: forkName,
2432 Knot: knot,
2433 Rkey: rkey,
2434 Source: sourceAt,
2435 }
2436
2437 span.SetAttributes(attribute.String("rkey", rkey))
2438
2439 tx, err := s.db.BeginTx(ctx, nil)
2440 if err != nil {
2441 log.Println(err)
2442 span.RecordError(err)
2443 span.SetStatus(codes.Error, "failed to begin transaction")
2444 s.pages.Notice(w, "repo", "Failed to save repository information.")
2445 return
2446 }
2447 defer func() {
2448 tx.Rollback()
2449 err = s.enforcer.E.LoadPolicy()
2450 if err != nil {
2451 log.Println("failed to rollback policies")
2452 span.RecordError(err)
2453 }
2454 }()
2455
2456 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
2457 if err != nil {
2458 span.RecordError(err)
2459 span.SetStatus(codes.Error, "failed to fork repo on knot server")
2460 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
2461 return
2462 }
2463
2464 span.SetAttributes(attribute.Int("fork_response_status", resp.StatusCode))
2465
2466 switch resp.StatusCode {
2467 case http.StatusConflict:
2468 span.SetAttributes(attribute.Bool("name_conflict", true))
2469 s.pages.Notice(w, "repo", "A repository with that name already exists.")
2470 return
2471 case http.StatusInternalServerError:
2472 span.SetAttributes(attribute.Bool("server_error", true))
2473 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
2474 return
2475 case http.StatusNoContent:
2476 // continue
2477 }
2478
2479 xrpcClient, _ := s.auth.AuthorizedClient(r)
2480
2481 createdAt := time.Now().Format(time.RFC3339)
2482 atresp, err := comatproto.RepoPutRecord(ctx, xrpcClient, &comatproto.RepoPutRecord_Input{
2483 Collection: tangled.RepoNSID,
2484 Repo: user.Did,
2485 Rkey: rkey,
2486 Record: &lexutil.LexiconTypeDecoder{
2487 Val: &tangled.Repo{
2488 Knot: repo.Knot,
2489 Name: repo.Name,
2490 CreatedAt: createdAt,
2491 Owner: user.Did,
2492 Source: &sourceAt,
2493 }},
2494 })
2495 if err != nil {
2496 log.Printf("failed to create record: %s", err)
2497 span.RecordError(err)
2498 span.SetStatus(codes.Error, "failed to create record in PDS")
2499 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
2500 return
2501 }
2502 log.Println("created repo record: ", atresp.Uri)
2503 span.SetAttributes(attribute.String("repo_uri", atresp.Uri))
2504
2505 repo.AtUri = atresp.Uri
2506 err = db.AddRepo(ctx, tx, repo)
2507 if err != nil {
2508 log.Println(err)
2509 span.RecordError(err)
2510 span.SetStatus(codes.Error, "failed to add repo to database")
2511 s.pages.Notice(w, "repo", "Failed to save repository information.")
2512 return
2513 }
2514
2515 // acls
2516 p, _ := securejoin.SecureJoin(user.Did, forkName)
2517 err = s.enforcer.AddRepo(user.Did, knot, p)
2518 if err != nil {
2519 log.Println(err)
2520 span.RecordError(err)
2521 span.SetStatus(codes.Error, "failed to set up repository permissions")
2522 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2523 return
2524 }
2525
2526 err = tx.Commit()
2527 if err != nil {
2528 log.Println("failed to commit changes", err)
2529 span.RecordError(err)
2530 span.SetStatus(codes.Error, "failed to commit transaction")
2531 http.Error(w, err.Error(), http.StatusInternalServerError)
2532 return
2533 }
2534
2535 err = s.enforcer.E.SavePolicy()
2536 if err != nil {
2537 log.Println("failed to update ACLs", err)
2538 span.RecordError(err)
2539 span.SetStatus(codes.Error, "failed to save policy")
2540 http.Error(w, err.Error(), http.StatusInternalServerError)
2541 return
2542 }
2543
2544 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
2545 return
2546 }
2547}