this repo has no description
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "path/filepath"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19
20 "tangled.sh/tangled.sh/core/api/tangled"
21 "tangled.sh/tangled.sh/core/appview/commitverify"
22 "tangled.sh/tangled.sh/core/appview/config"
23 "tangled.sh/tangled.sh/core/appview/db"
24 "tangled.sh/tangled.sh/core/appview/notify"
25 "tangled.sh/tangled.sh/core/appview/oauth"
26 "tangled.sh/tangled.sh/core/appview/pages"
27 "tangled.sh/tangled.sh/core/appview/pages/markup"
28 "tangled.sh/tangled.sh/core/appview/reporesolver"
29 "tangled.sh/tangled.sh/core/eventconsumer"
30 "tangled.sh/tangled.sh/core/idresolver"
31 "tangled.sh/tangled.sh/core/knotclient"
32 "tangled.sh/tangled.sh/core/patchutil"
33 "tangled.sh/tangled.sh/core/rbac"
34 "tangled.sh/tangled.sh/core/tid"
35 "tangled.sh/tangled.sh/core/types"
36
37 securejoin "github.com/cyphar/filepath-securejoin"
38 "github.com/go-chi/chi/v5"
39 "github.com/go-git/go-git/v5/plumbing"
40 "github.com/gorilla/feeds"
41
42 comatproto "github.com/bluesky-social/indigo/api/atproto"
43 "github.com/bluesky-social/indigo/atproto/syntax"
44 lexutil "github.com/bluesky-social/indigo/lex/util"
45)
46
47type Repo struct {
48 repoResolver *reporesolver.RepoResolver
49 idResolver *idresolver.Resolver
50 config *config.Config
51 oauth *oauth.OAuth
52 pages *pages.Pages
53 spindlestream *eventconsumer.Consumer
54 db *db.DB
55 enforcer *rbac.Enforcer
56 notifier notify.Notifier
57 logger *slog.Logger
58}
59
60func New(
61 oauth *oauth.OAuth,
62 repoResolver *reporesolver.RepoResolver,
63 pages *pages.Pages,
64 spindlestream *eventconsumer.Consumer,
65 idResolver *idresolver.Resolver,
66 db *db.DB,
67 config *config.Config,
68 notifier notify.Notifier,
69 enforcer *rbac.Enforcer,
70 logger *slog.Logger,
71) *Repo {
72 return &Repo{oauth: oauth,
73 repoResolver: repoResolver,
74 pages: pages,
75 idResolver: idResolver,
76 config: config,
77 spindlestream: spindlestream,
78 db: db,
79 notifier: notifier,
80 enforcer: enforcer,
81 logger: logger,
82 }
83}
84
85func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
86 refParam := chi.URLParam(r, "ref")
87 f, err := rp.repoResolver.Resolve(r)
88 if err != nil {
89 log.Println("failed to get repo and knot", err)
90 return
91 }
92
93 var uri string
94 if rp.config.Core.Dev {
95 uri = "http"
96 } else {
97 uri = "https"
98 }
99 url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
100
101 http.Redirect(w, r, url, http.StatusFound)
102}
103
104func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
105 f, err := rp.repoResolver.Resolve(r)
106 if err != nil {
107 log.Println("failed to fully resolve repo", err)
108 return
109 }
110
111 page := 1
112 if r.URL.Query().Get("page") != "" {
113 page, err = strconv.Atoi(r.URL.Query().Get("page"))
114 if err != nil {
115 page = 1
116 }
117 }
118
119 ref := chi.URLParam(r, "ref")
120
121 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
122 if err != nil {
123 log.Println("failed to create unsigned client", err)
124 return
125 }
126
127 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
128 if err != nil {
129 log.Println("failed to reach knotserver", err)
130 return
131 }
132
133 tagResult, err := us.Tags(f.OwnerDid(), f.Name)
134 if err != nil {
135 log.Println("failed to reach knotserver", err)
136 return
137 }
138
139 tagMap := make(map[string][]string)
140 for _, tag := range tagResult.Tags {
141 hash := tag.Hash
142 if tag.Tag != nil {
143 hash = tag.Tag.Target.String()
144 }
145 tagMap[hash] = append(tagMap[hash], tag.Name)
146 }
147
148 branchResult, err := us.Branches(f.OwnerDid(), f.Name)
149 if err != nil {
150 log.Println("failed to reach knotserver", err)
151 return
152 }
153
154 for _, branch := range branchResult.Branches {
155 hash := branch.Hash
156 tagMap[hash] = append(tagMap[hash], branch.Name)
157 }
158
159 user := rp.oauth.GetUser(r)
160
161 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
162 if err != nil {
163 log.Println("failed to fetch email to did mapping", err)
164 }
165
166 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
167 if err != nil {
168 log.Println(err)
169 }
170
171 repoInfo := f.RepoInfo(user)
172
173 var shas []string
174 for _, c := range repolog.Commits {
175 shas = append(shas, c.Hash.String())
176 }
177 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
178 if err != nil {
179 log.Println(err)
180 // non-fatal
181 }
182
183 rp.pages.RepoLog(w, pages.RepoLogParams{
184 LoggedInUser: user,
185 TagMap: tagMap,
186 RepoInfo: repoInfo,
187 RepoLogResponse: *repolog,
188 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
189 VerifiedCommits: vc,
190 Pipelines: pipelines,
191 })
192}
193
194func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
195 f, err := rp.repoResolver.Resolve(r)
196 if err != nil {
197 log.Println("failed to get repo and knot", err)
198 w.WriteHeader(http.StatusBadRequest)
199 return
200 }
201
202 user := rp.oauth.GetUser(r)
203 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
204 RepoInfo: f.RepoInfo(user),
205 })
206}
207
208func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
209 f, err := rp.repoResolver.Resolve(r)
210 if err != nil {
211 log.Println("failed to get repo and knot", err)
212 w.WriteHeader(http.StatusBadRequest)
213 return
214 }
215
216 repoAt := f.RepoAt()
217 rkey := repoAt.RecordKey().String()
218 if rkey == "" {
219 log.Println("invalid aturi for repo", err)
220 w.WriteHeader(http.StatusInternalServerError)
221 return
222 }
223
224 user := rp.oauth.GetUser(r)
225
226 switch r.Method {
227 case http.MethodGet:
228 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
229 RepoInfo: f.RepoInfo(user),
230 })
231 return
232 case http.MethodPut:
233 newDescription := r.FormValue("description")
234 client, err := rp.oauth.AuthorizedClient(r)
235 if err != nil {
236 log.Println("failed to get client")
237 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
238 return
239 }
240
241 // optimistic update
242 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
243 if err != nil {
244 log.Println("failed to perferom update-description query", err)
245 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
246 return
247 }
248
249 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
250 //
251 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
252 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
253 if err != nil {
254 // failed to get record
255 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
256 return
257 }
258 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
259 Collection: tangled.RepoNSID,
260 Repo: user.Did,
261 Rkey: rkey,
262 SwapRecord: ex.Cid,
263 Record: &lexutil.LexiconTypeDecoder{
264 Val: &tangled.Repo{
265 Knot: f.Knot,
266 Name: f.Name,
267 Owner: user.Did,
268 CreatedAt: f.Created.Format(time.RFC3339),
269 Description: &newDescription,
270 Spindle: &f.Spindle,
271 },
272 },
273 })
274
275 if err != nil {
276 log.Println("failed to perferom update-description query", err)
277 // failed to get record
278 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
279 return
280 }
281
282 newRepoInfo := f.RepoInfo(user)
283 newRepoInfo.Description = newDescription
284
285 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
286 RepoInfo: newRepoInfo,
287 })
288 return
289 }
290}
291
292func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
293 const feedLimitPerType = 100
294
295 pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
296 if err != nil {
297 return nil, err
298 }
299
300 issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
301 if err != nil {
302 return nil, err
303 }
304
305 feed := &feeds.Feed{
306 Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
307 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
308 Items: make([]*feeds.Item, 0),
309 Updated: time.UnixMilli(0),
310 }
311
312 for _, pull := range pulls {
313 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
314 if err != nil {
315 return nil, err
316 }
317
318 var state string
319 if pull.State == db.PullOpen {
320 state = "opened"
321 } else {
322 state = pull.State.String()
323 }
324 mergedAtRounds := ""
325 if pull.State == db.PullMerged {
326 mergedAtRounds = fmt.Sprintf(" (on round #%d)", pull.LastRoundNumber())
327 }
328 item := &feeds.Item{
329 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
330 Description: fmt.Sprintf("@%s %s pull request #%d%s in %s", owner.Handle, state, pull.PullId, mergedAtRounds, f.OwnerSlashRepo()),
331 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
332 Created: pull.Created,
333 Author: &feeds.Author{
334 Name: fmt.Sprintf("@%s", owner.Handle),
335 },
336 }
337 feed.Items = append(feed.Items, item)
338
339 for _, round := range pull.Submissions {
340 if round == nil || round.RoundNumber == 0 {
341 continue
342 }
343 item := &feeds.Item{
344 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
345 Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
346 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
347 Created: round.Created,
348 Author: &feeds.Author{
349 Name: fmt.Sprintf("@%s", owner.Handle),
350 },
351 }
352 feed.Items = append(feed.Items, item)
353 }
354 }
355
356 for _, issue := range issues {
357 owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
358 if err != nil {
359 return nil, err
360 }
361 var state string
362 if issue.Open {
363 state = "opened"
364 } else {
365 state = "closed"
366 }
367 item := &feeds.Item{
368 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
369 Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
370 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
371 Created: issue.Created,
372 Author: &feeds.Author{
373 Name: fmt.Sprintf("@%s", owner.Handle),
374 },
375 }
376 feed.Items = append(feed.Items, item)
377 }
378
379 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
380 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
381 })
382 if len(feed.Items) > 0 {
383 feed.Updated = feed.Items[0].Created
384 }
385
386 return feed, nil
387}
388
389func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
390 f, err := rp.repoResolver.Resolve(r)
391 if err != nil {
392 log.Println("failed to fully resolve repo:", err)
393 return
394 }
395
396 feed, err := rp.getRepoFeed(r.Context(), f)
397 if err != nil {
398 log.Println("failed to get repo feed:", err)
399 rp.pages.Error500(w)
400 return
401 }
402
403 atom, err := feed.ToAtom()
404 if err != nil {
405 rp.pages.Error500(w)
406 return
407 }
408
409 w.Header().Set("content-type", "application/atom+xml")
410 w.Write([]byte(atom))
411}
412
413func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
414 f, err := rp.repoResolver.Resolve(r)
415 if err != nil {
416 log.Println("failed to fully resolve repo", err)
417 return
418 }
419 ref := chi.URLParam(r, "ref")
420 protocol := "http"
421 if !rp.config.Core.Dev {
422 protocol = "https"
423 }
424
425 var diffOpts types.DiffOpts
426 if d := r.URL.Query().Get("diff"); d == "split" {
427 diffOpts.Split = true
428 }
429
430 if !plumbing.IsHash(ref) {
431 rp.pages.Error404(w)
432 return
433 }
434
435 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
436 if err != nil {
437 log.Println("failed to reach knotserver", err)
438 return
439 }
440
441 body, err := io.ReadAll(resp.Body)
442 if err != nil {
443 log.Printf("Error reading response body: %v", err)
444 return
445 }
446
447 var result types.RepoCommitResponse
448 err = json.Unmarshal(body, &result)
449 if err != nil {
450 log.Println("failed to parse response:", err)
451 return
452 }
453
454 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
455 if err != nil {
456 log.Println("failed to get email to did mapping:", err)
457 }
458
459 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
460 if err != nil {
461 log.Println(err)
462 }
463
464 user := rp.oauth.GetUser(r)
465 repoInfo := f.RepoInfo(user)
466 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
467 if err != nil {
468 log.Println(err)
469 // non-fatal
470 }
471 var pipeline *db.Pipeline
472 if p, ok := pipelines[result.Diff.Commit.This]; ok {
473 pipeline = &p
474 }
475
476 rp.pages.RepoCommit(w, pages.RepoCommitParams{
477 LoggedInUser: user,
478 RepoInfo: f.RepoInfo(user),
479 RepoCommitResponse: result,
480 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
481 VerifiedCommit: vc,
482 Pipeline: pipeline,
483 DiffOpts: diffOpts,
484 })
485}
486
487func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
488 f, err := rp.repoResolver.Resolve(r)
489 if err != nil {
490 log.Println("failed to fully resolve repo", err)
491 return
492 }
493
494 ref := chi.URLParam(r, "ref")
495 treePath := chi.URLParam(r, "*")
496 protocol := "http"
497 if !rp.config.Core.Dev {
498 protocol = "https"
499 }
500 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
501 if err != nil {
502 log.Println("failed to reach knotserver", err)
503 return
504 }
505
506 body, err := io.ReadAll(resp.Body)
507 if err != nil {
508 log.Printf("Error reading response body: %v", err)
509 return
510 }
511
512 var result types.RepoTreeResponse
513 err = json.Unmarshal(body, &result)
514 if err != nil {
515 log.Println("failed to parse response:", err)
516 return
517 }
518
519 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
520 // so we can safely redirect to the "parent" (which is the same file).
521 unescapedTreePath, _ := url.PathUnescape(treePath)
522 if len(result.Files) == 0 && result.Parent == unescapedTreePath {
523 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
524 return
525 }
526
527 user := rp.oauth.GetUser(r)
528
529 var breadcrumbs [][]string
530 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
531 if treePath != "" {
532 for idx, elem := range strings.Split(treePath, "/") {
533 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
534 }
535 }
536
537 sortFiles(result.Files)
538
539 rp.pages.RepoTree(w, pages.RepoTreeParams{
540 LoggedInUser: user,
541 BreadCrumbs: breadcrumbs,
542 TreePath: treePath,
543 RepoInfo: f.RepoInfo(user),
544 RepoTreeResponse: result,
545 })
546}
547
548func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
549 f, err := rp.repoResolver.Resolve(r)
550 if err != nil {
551 log.Println("failed to get repo and knot", err)
552 return
553 }
554
555 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
556 if err != nil {
557 log.Println("failed to create unsigned client", err)
558 return
559 }
560
561 result, err := us.Tags(f.OwnerDid(), f.Name)
562 if err != nil {
563 log.Println("failed to reach knotserver", err)
564 return
565 }
566
567 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
568 if err != nil {
569 log.Println("failed grab artifacts", err)
570 return
571 }
572
573 // convert artifacts to map for easy UI building
574 artifactMap := make(map[plumbing.Hash][]db.Artifact)
575 for _, a := range artifacts {
576 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
577 }
578
579 var danglingArtifacts []db.Artifact
580 for _, a := range artifacts {
581 found := false
582 for _, t := range result.Tags {
583 if t.Tag != nil {
584 if t.Tag.Hash == a.Tag {
585 found = true
586 }
587 }
588 }
589
590 if !found {
591 danglingArtifacts = append(danglingArtifacts, a)
592 }
593 }
594
595 user := rp.oauth.GetUser(r)
596 rp.pages.RepoTags(w, pages.RepoTagsParams{
597 LoggedInUser: user,
598 RepoInfo: f.RepoInfo(user),
599 RepoTagsResponse: *result,
600 ArtifactMap: artifactMap,
601 DanglingArtifacts: danglingArtifacts,
602 })
603}
604
605func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
606 f, err := rp.repoResolver.Resolve(r)
607 if err != nil {
608 log.Println("failed to get repo and knot", err)
609 return
610 }
611
612 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
613 if err != nil {
614 log.Println("failed to create unsigned client", err)
615 return
616 }
617
618 result, err := us.Branches(f.OwnerDid(), f.Name)
619 if err != nil {
620 log.Println("failed to reach knotserver", err)
621 return
622 }
623
624 sortBranches(result.Branches)
625
626 user := rp.oauth.GetUser(r)
627 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
628 LoggedInUser: user,
629 RepoInfo: f.RepoInfo(user),
630 RepoBranchesResponse: *result,
631 })
632}
633
634func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
635 f, err := rp.repoResolver.Resolve(r)
636 if err != nil {
637 log.Println("failed to get repo and knot", err)
638 return
639 }
640
641 ref := chi.URLParam(r, "ref")
642 filePath := chi.URLParam(r, "*")
643 protocol := "http"
644 if !rp.config.Core.Dev {
645 protocol = "https"
646 }
647 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
648 if err != nil {
649 log.Println("failed to reach knotserver", err)
650 return
651 }
652
653 body, err := io.ReadAll(resp.Body)
654 if err != nil {
655 log.Printf("Error reading response body: %v", err)
656 return
657 }
658
659 var result types.RepoBlobResponse
660 err = json.Unmarshal(body, &result)
661 if err != nil {
662 log.Println("failed to parse response:", err)
663 return
664 }
665
666 var breadcrumbs [][]string
667 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
668 if filePath != "" {
669 for idx, elem := range strings.Split(filePath, "/") {
670 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
671 }
672 }
673
674 showRendered := false
675 renderToggle := false
676
677 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
678 renderToggle = true
679 showRendered = r.URL.Query().Get("code") != "true"
680 }
681
682 var unsupported bool
683 var isImage bool
684 var isVideo bool
685 var contentSrc string
686
687 if result.IsBinary {
688 ext := strings.ToLower(filepath.Ext(result.Path))
689 switch ext {
690 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
691 isImage = true
692 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
693 isVideo = true
694 default:
695 unsupported = true
696 }
697
698 // fetch the actual binary content like in RepoBlobRaw
699
700 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
701 contentSrc = blobURL
702 if !rp.config.Core.Dev {
703 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
704 }
705 }
706
707 user := rp.oauth.GetUser(r)
708 rp.pages.RepoBlob(w, pages.RepoBlobParams{
709 LoggedInUser: user,
710 RepoInfo: f.RepoInfo(user),
711 RepoBlobResponse: result,
712 BreadCrumbs: breadcrumbs,
713 ShowRendered: showRendered,
714 RenderToggle: renderToggle,
715 Unsupported: unsupported,
716 IsImage: isImage,
717 IsVideo: isVideo,
718 ContentSrc: contentSrc,
719 })
720}
721
722func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
723 f, err := rp.repoResolver.Resolve(r)
724 if err != nil {
725 log.Println("failed to get repo and knot", err)
726 w.WriteHeader(http.StatusBadRequest)
727 return
728 }
729
730 ref := chi.URLParam(r, "ref")
731 filePath := chi.URLParam(r, "*")
732
733 protocol := "http"
734 if !rp.config.Core.Dev {
735 protocol = "https"
736 }
737
738 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
739
740 req, err := http.NewRequest("GET", blobURL, nil)
741 if err != nil {
742 log.Println("failed to create request", err)
743 return
744 }
745
746 // forward the If-None-Match header
747 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
748 req.Header.Set("If-None-Match", clientETag)
749 }
750
751 client := &http.Client{}
752 resp, err := client.Do(req)
753 if err != nil {
754 log.Println("failed to reach knotserver", err)
755 rp.pages.Error503(w)
756 return
757 }
758 defer resp.Body.Close()
759
760 // forward 304 not modified
761 if resp.StatusCode == http.StatusNotModified {
762 w.WriteHeader(http.StatusNotModified)
763 return
764 }
765
766 if resp.StatusCode != http.StatusOK {
767 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
768 w.WriteHeader(resp.StatusCode)
769 _, _ = io.Copy(w, resp.Body)
770 return
771 }
772
773 contentType := resp.Header.Get("Content-Type")
774 body, err := io.ReadAll(resp.Body)
775 if err != nil {
776 log.Printf("error reading response body from knotserver: %v", err)
777 w.WriteHeader(http.StatusInternalServerError)
778 return
779 }
780
781 if strings.Contains(contentType, "text/plain") {
782 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
783 w.Write(body)
784 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
785 w.Header().Set("Content-Type", contentType)
786 w.Write(body)
787 } else {
788 w.WriteHeader(http.StatusUnsupportedMediaType)
789 w.Write([]byte("unsupported content type"))
790 return
791 }
792}
793
794// modify the spindle configured for this repo
795func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
796 user := rp.oauth.GetUser(r)
797 l := rp.logger.With("handler", "EditSpindle")
798 l = l.With("did", user.Did)
799 l = l.With("handle", user.Handle)
800
801 errorId := "operation-error"
802 fail := func(msg string, err error) {
803 l.Error(msg, "err", err)
804 rp.pages.Notice(w, errorId, msg)
805 }
806
807 f, err := rp.repoResolver.Resolve(r)
808 if err != nil {
809 fail("Failed to resolve repo. Try again later", err)
810 return
811 }
812
813 repoAt := f.RepoAt()
814 rkey := repoAt.RecordKey().String()
815 if rkey == "" {
816 fail("Failed to resolve repo. Try again later", err)
817 return
818 }
819
820 newSpindle := r.FormValue("spindle")
821 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
822 client, err := rp.oauth.AuthorizedClient(r)
823 if err != nil {
824 fail("Failed to authorize. Try again later.", err)
825 return
826 }
827
828 if !removingSpindle {
829 // ensure that this is a valid spindle for this user
830 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
831 if err != nil {
832 fail("Failed to find spindles. Try again later.", err)
833 return
834 }
835
836 if !slices.Contains(validSpindles, newSpindle) {
837 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
838 return
839 }
840 }
841
842 spindlePtr := &newSpindle
843 if removingSpindle {
844 spindlePtr = nil
845 }
846
847 // optimistic update
848 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
849 if err != nil {
850 fail("Failed to update spindle. Try again later.", err)
851 return
852 }
853
854 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
855 if err != nil {
856 fail("Failed to update spindle, no record found on PDS.", err)
857 return
858 }
859 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
860 Collection: tangled.RepoNSID,
861 Repo: user.Did,
862 Rkey: rkey,
863 SwapRecord: ex.Cid,
864 Record: &lexutil.LexiconTypeDecoder{
865 Val: &tangled.Repo{
866 Knot: f.Knot,
867 Name: f.Name,
868 Owner: user.Did,
869 CreatedAt: f.Created.Format(time.RFC3339),
870 Description: &f.Description,
871 Spindle: spindlePtr,
872 },
873 },
874 })
875
876 if err != nil {
877 fail("Failed to update spindle, unable to save to PDS.", err)
878 return
879 }
880
881 if !removingSpindle {
882 // add this spindle to spindle stream
883 rp.spindlestream.AddSource(
884 context.Background(),
885 eventconsumer.NewSpindleSource(newSpindle),
886 )
887 }
888
889 rp.pages.HxRefresh(w)
890}
891
892func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
893 user := rp.oauth.GetUser(r)
894 l := rp.logger.With("handler", "AddCollaborator")
895 l = l.With("did", user.Did)
896 l = l.With("handle", user.Handle)
897
898 f, err := rp.repoResolver.Resolve(r)
899 if err != nil {
900 l.Error("failed to get repo and knot", "err", err)
901 return
902 }
903
904 errorId := "add-collaborator-error"
905 fail := func(msg string, err error) {
906 l.Error(msg, "err", err)
907 rp.pages.Notice(w, errorId, msg)
908 }
909
910 collaborator := r.FormValue("collaborator")
911 if collaborator == "" {
912 fail("Invalid form.", nil)
913 return
914 }
915
916 // remove a single leading `@`, to make @handle work with ResolveIdent
917 collaborator = strings.TrimPrefix(collaborator, "@")
918
919 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
920 if err != nil {
921 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
922 return
923 }
924
925 if collaboratorIdent.DID.String() == user.Did {
926 fail("You seem to be adding yourself as a collaborator.", nil)
927 return
928 }
929 l = l.With("collaborator", collaboratorIdent.Handle)
930 l = l.With("knot", f.Knot)
931
932 // announce this relation into the firehose, store into owners' pds
933 client, err := rp.oauth.AuthorizedClient(r)
934 if err != nil {
935 fail("Failed to write to PDS.", err)
936 return
937 }
938
939 // emit a record
940 currentUser := rp.oauth.GetUser(r)
941 rkey := tid.TID()
942 createdAt := time.Now()
943 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
944 Collection: tangled.RepoCollaboratorNSID,
945 Repo: currentUser.Did,
946 Rkey: rkey,
947 Record: &lexutil.LexiconTypeDecoder{
948 Val: &tangled.RepoCollaborator{
949 Subject: collaboratorIdent.DID.String(),
950 Repo: string(f.RepoAt()),
951 CreatedAt: createdAt.Format(time.RFC3339),
952 }},
953 })
954 // invalid record
955 if err != nil {
956 fail("Failed to write record to PDS.", err)
957 return
958 }
959 l = l.With("at-uri", resp.Uri)
960 l.Info("wrote record to PDS")
961
962 l.Info("adding to knot")
963 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
964 if err != nil {
965 fail("Failed to add to knot.", err)
966 return
967 }
968
969 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
970 if err != nil {
971 fail("Failed to add to knot.", err)
972 return
973 }
974
975 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
976 if err != nil {
977 fail("Knot was unreachable.", err)
978 return
979 }
980
981 if ksResp.StatusCode != http.StatusNoContent {
982 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
983 return
984 }
985
986 tx, err := rp.db.BeginTx(r.Context(), nil)
987 if err != nil {
988 fail("Failed to add collaborator.", err)
989 return
990 }
991 defer func() {
992 tx.Rollback()
993 err = rp.enforcer.E.LoadPolicy()
994 if err != nil {
995 fail("Failed to add collaborator.", err)
996 }
997 }()
998
999 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
1000 if err != nil {
1001 fail("Failed to add collaborator permissions.", err)
1002 return
1003 }
1004
1005 err = db.AddCollaborator(rp.db, db.Collaborator{
1006 Did: syntax.DID(currentUser.Did),
1007 Rkey: rkey,
1008 SubjectDid: collaboratorIdent.DID,
1009 RepoAt: f.RepoAt(),
1010 Created: createdAt,
1011 })
1012 if err != nil {
1013 fail("Failed to add collaborator.", err)
1014 return
1015 }
1016
1017 err = tx.Commit()
1018 if err != nil {
1019 fail("Failed to add collaborator.", err)
1020 return
1021 }
1022
1023 err = rp.enforcer.E.SavePolicy()
1024 if err != nil {
1025 fail("Failed to update collaborator permissions.", err)
1026 return
1027 }
1028
1029 rp.pages.HxRefresh(w)
1030}
1031
1032func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1033 user := rp.oauth.GetUser(r)
1034
1035 f, err := rp.repoResolver.Resolve(r)
1036 if err != nil {
1037 log.Println("failed to get repo and knot", err)
1038 return
1039 }
1040
1041 // remove record from pds
1042 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1043 if err != nil {
1044 log.Println("failed to get authorized client", err)
1045 return
1046 }
1047 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1048 Collection: tangled.RepoNSID,
1049 Repo: user.Did,
1050 Rkey: f.Rkey,
1051 })
1052 if err != nil {
1053 log.Printf("failed to delete record: %s", err)
1054 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
1055 return
1056 }
1057 log.Println("removed repo record ", f.RepoAt().String())
1058
1059 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1060 if err != nil {
1061 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1062 return
1063 }
1064
1065 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1066 if err != nil {
1067 log.Println("failed to create client to ", f.Knot)
1068 return
1069 }
1070
1071 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
1072 if err != nil {
1073 log.Printf("failed to make request to %s: %s", f.Knot, err)
1074 return
1075 }
1076
1077 if ksResp.StatusCode != http.StatusNoContent {
1078 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
1079 } else {
1080 log.Println("removed repo from knot ", f.Knot)
1081 }
1082
1083 tx, err := rp.db.BeginTx(r.Context(), nil)
1084 if err != nil {
1085 log.Println("failed to start tx")
1086 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1087 return
1088 }
1089 defer func() {
1090 tx.Rollback()
1091 err = rp.enforcer.E.LoadPolicy()
1092 if err != nil {
1093 log.Println("failed to rollback policies")
1094 }
1095 }()
1096
1097 // remove collaborator RBAC
1098 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1099 if err != nil {
1100 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1101 return
1102 }
1103 for _, c := range repoCollaborators {
1104 did := c[0]
1105 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1106 }
1107 log.Println("removed collaborators")
1108
1109 // remove repo RBAC
1110 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1111 if err != nil {
1112 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1113 return
1114 }
1115
1116 // remove repo from db
1117 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1118 if err != nil {
1119 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1120 return
1121 }
1122 log.Println("removed repo from db")
1123
1124 err = tx.Commit()
1125 if err != nil {
1126 log.Println("failed to commit changes", err)
1127 http.Error(w, err.Error(), http.StatusInternalServerError)
1128 return
1129 }
1130
1131 err = rp.enforcer.E.SavePolicy()
1132 if err != nil {
1133 log.Println("failed to update ACLs", err)
1134 http.Error(w, err.Error(), http.StatusInternalServerError)
1135 return
1136 }
1137
1138 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1139}
1140
1141func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1142 f, err := rp.repoResolver.Resolve(r)
1143 if err != nil {
1144 log.Println("failed to get repo and knot", err)
1145 return
1146 }
1147
1148 branch := r.FormValue("branch")
1149 if branch == "" {
1150 http.Error(w, "malformed form", http.StatusBadRequest)
1151 return
1152 }
1153
1154 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1155 if err != nil {
1156 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1157 return
1158 }
1159
1160 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1161 if err != nil {
1162 log.Println("failed to create client to ", f.Knot)
1163 return
1164 }
1165
1166 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
1167 if err != nil {
1168 log.Printf("failed to make request to %s: %s", f.Knot, err)
1169 return
1170 }
1171
1172 if ksResp.StatusCode != http.StatusNoContent {
1173 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
1174 return
1175 }
1176
1177 w.Write(fmt.Append(nil, "default branch set to: ", branch))
1178}
1179
1180func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1181 user := rp.oauth.GetUser(r)
1182 l := rp.logger.With("handler", "Secrets")
1183 l = l.With("handle", user.Handle)
1184 l = l.With("did", user.Did)
1185
1186 f, err := rp.repoResolver.Resolve(r)
1187 if err != nil {
1188 log.Println("failed to get repo and knot", err)
1189 return
1190 }
1191
1192 if f.Spindle == "" {
1193 log.Println("empty spindle cannot add/rm secret", err)
1194 return
1195 }
1196
1197 lxm := tangled.RepoAddSecretNSID
1198 if r.Method == http.MethodDelete {
1199 lxm = tangled.RepoRemoveSecretNSID
1200 }
1201
1202 spindleClient, err := rp.oauth.ServiceClient(
1203 r,
1204 oauth.WithService(f.Spindle),
1205 oauth.WithLxm(lxm),
1206 oauth.WithExp(60),
1207 oauth.WithDev(rp.config.Core.Dev),
1208 )
1209 if err != nil {
1210 log.Println("failed to create spindle client", err)
1211 return
1212 }
1213
1214 key := r.FormValue("key")
1215 if key == "" {
1216 w.WriteHeader(http.StatusBadRequest)
1217 return
1218 }
1219
1220 switch r.Method {
1221 case http.MethodPut:
1222 errorId := "add-secret-error"
1223
1224 value := r.FormValue("value")
1225 if value == "" {
1226 w.WriteHeader(http.StatusBadRequest)
1227 return
1228 }
1229
1230 err = tangled.RepoAddSecret(
1231 r.Context(),
1232 spindleClient,
1233 &tangled.RepoAddSecret_Input{
1234 Repo: f.RepoAt().String(),
1235 Key: key,
1236 Value: value,
1237 },
1238 )
1239 if err != nil {
1240 l.Error("Failed to add secret.", "err", err)
1241 rp.pages.Notice(w, errorId, "Failed to add secret.")
1242 return
1243 }
1244
1245 case http.MethodDelete:
1246 errorId := "operation-error"
1247
1248 err = tangled.RepoRemoveSecret(
1249 r.Context(),
1250 spindleClient,
1251 &tangled.RepoRemoveSecret_Input{
1252 Repo: f.RepoAt().String(),
1253 Key: key,
1254 },
1255 )
1256 if err != nil {
1257 l.Error("Failed to delete secret.", "err", err)
1258 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1259 return
1260 }
1261 }
1262
1263 rp.pages.HxRefresh(w)
1264}
1265
1266type tab = map[string]any
1267
1268var (
1269 // would be great to have ordered maps right about now
1270 settingsTabs []tab = []tab{
1271 {"Name": "general", "Icon": "sliders-horizontal"},
1272 {"Name": "access", "Icon": "users"},
1273 {"Name": "pipelines", "Icon": "layers-2"},
1274 }
1275)
1276
1277func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1278 tabVal := r.URL.Query().Get("tab")
1279 if tabVal == "" {
1280 tabVal = "general"
1281 }
1282
1283 switch tabVal {
1284 case "general":
1285 rp.generalSettings(w, r)
1286
1287 case "access":
1288 rp.accessSettings(w, r)
1289
1290 case "pipelines":
1291 rp.pipelineSettings(w, r)
1292 }
1293
1294 // user := rp.oauth.GetUser(r)
1295 // repoCollaborators, err := f.Collaborators(r.Context())
1296 // if err != nil {
1297 // log.Println("failed to get collaborators", err)
1298 // }
1299
1300 // isCollaboratorInviteAllowed := false
1301 // if user != nil {
1302 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1303 // if err == nil && ok {
1304 // isCollaboratorInviteAllowed = true
1305 // }
1306 // }
1307
1308 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1309 // if err != nil {
1310 // log.Println("failed to create unsigned client", err)
1311 // return
1312 // }
1313
1314 // result, err := us.Branches(f.OwnerDid(), f.Name)
1315 // if err != nil {
1316 // log.Println("failed to reach knotserver", err)
1317 // return
1318 // }
1319
1320 // // all spindles that this user is a member of
1321 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1322 // if err != nil {
1323 // log.Println("failed to fetch spindles", err)
1324 // return
1325 // }
1326
1327 // var secrets []*tangled.RepoListSecrets_Secret
1328 // if f.Spindle != "" {
1329 // if spindleClient, err := rp.oauth.ServiceClient(
1330 // r,
1331 // oauth.WithService(f.Spindle),
1332 // oauth.WithLxm(tangled.RepoListSecretsNSID),
1333 // oauth.WithDev(rp.config.Core.Dev),
1334 // ); err != nil {
1335 // log.Println("failed to create spindle client", err)
1336 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1337 // log.Println("failed to fetch secrets", err)
1338 // } else {
1339 // secrets = resp.Secrets
1340 // }
1341 // }
1342
1343 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1344 // LoggedInUser: user,
1345 // RepoInfo: f.RepoInfo(user),
1346 // Collaborators: repoCollaborators,
1347 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1348 // Branches: result.Branches,
1349 // Spindles: spindles,
1350 // CurrentSpindle: f.Spindle,
1351 // Secrets: secrets,
1352 // })
1353}
1354
1355func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1356 f, err := rp.repoResolver.Resolve(r)
1357 user := rp.oauth.GetUser(r)
1358
1359 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1360 if err != nil {
1361 log.Println("failed to create unsigned client", err)
1362 return
1363 }
1364
1365 result, err := us.Branches(f.OwnerDid(), f.Name)
1366 if err != nil {
1367 log.Println("failed to reach knotserver", err)
1368 return
1369 }
1370
1371 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1372 LoggedInUser: user,
1373 RepoInfo: f.RepoInfo(user),
1374 Branches: result.Branches,
1375 Tabs: settingsTabs,
1376 Tab: "general",
1377 })
1378}
1379
1380func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1381 f, err := rp.repoResolver.Resolve(r)
1382 user := rp.oauth.GetUser(r)
1383
1384 repoCollaborators, err := f.Collaborators(r.Context())
1385 if err != nil {
1386 log.Println("failed to get collaborators", err)
1387 }
1388
1389 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1390 LoggedInUser: user,
1391 RepoInfo: f.RepoInfo(user),
1392 Tabs: settingsTabs,
1393 Tab: "access",
1394 Collaborators: repoCollaborators,
1395 })
1396}
1397
1398func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1399 f, err := rp.repoResolver.Resolve(r)
1400 user := rp.oauth.GetUser(r)
1401
1402 // all spindles that the repo owner is a member of
1403 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1404 if err != nil {
1405 log.Println("failed to fetch spindles", err)
1406 return
1407 }
1408
1409 var secrets []*tangled.RepoListSecrets_Secret
1410 if f.Spindle != "" {
1411 if spindleClient, err := rp.oauth.ServiceClient(
1412 r,
1413 oauth.WithService(f.Spindle),
1414 oauth.WithLxm(tangled.RepoListSecretsNSID),
1415 oauth.WithExp(60),
1416 oauth.WithDev(rp.config.Core.Dev),
1417 ); err != nil {
1418 log.Println("failed to create spindle client", err)
1419 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1420 log.Println("failed to fetch secrets", err)
1421 } else {
1422 secrets = resp.Secrets
1423 }
1424 }
1425
1426 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1427 return strings.Compare(a.Key, b.Key)
1428 })
1429
1430 var dids []string
1431 for _, s := range secrets {
1432 dids = append(dids, s.CreatedBy)
1433 }
1434 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1435
1436 // convert to a more manageable form
1437 var niceSecret []map[string]any
1438 for id, s := range secrets {
1439 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1440 niceSecret = append(niceSecret, map[string]any{
1441 "Id": id,
1442 "Key": s.Key,
1443 "CreatedAt": when,
1444 "CreatedBy": resolvedIdents[id].Handle.String(),
1445 })
1446 }
1447
1448 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1449 LoggedInUser: user,
1450 RepoInfo: f.RepoInfo(user),
1451 Tabs: settingsTabs,
1452 Tab: "pipelines",
1453 Spindles: spindles,
1454 CurrentSpindle: f.Spindle,
1455 Secrets: niceSecret,
1456 })
1457}
1458
1459func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1460 ref := chi.URLParam(r, "ref")
1461
1462 user := rp.oauth.GetUser(r)
1463 f, err := rp.repoResolver.Resolve(r)
1464 if err != nil {
1465 log.Printf("failed to resolve source repo: %v", err)
1466 return
1467 }
1468
1469 switch r.Method {
1470 case http.MethodPost:
1471 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1472 if err != nil {
1473 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1474 return
1475 }
1476
1477 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1478 if err != nil {
1479 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1480 return
1481 }
1482
1483 var uri string
1484 if rp.config.Core.Dev {
1485 uri = "http"
1486 } else {
1487 uri = "https"
1488 }
1489 forkName := fmt.Sprintf("%s", f.Name)
1490 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1491
1492 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1493 if err != nil {
1494 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1495 return
1496 }
1497
1498 rp.pages.HxRefresh(w)
1499 return
1500 }
1501}
1502
1503func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1504 user := rp.oauth.GetUser(r)
1505 f, err := rp.repoResolver.Resolve(r)
1506 if err != nil {
1507 log.Printf("failed to resolve source repo: %v", err)
1508 return
1509 }
1510
1511 switch r.Method {
1512 case http.MethodGet:
1513 user := rp.oauth.GetUser(r)
1514 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1515 if err != nil {
1516 rp.pages.Notice(w, "repo", "Invalid user account.")
1517 return
1518 }
1519
1520 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1521 LoggedInUser: user,
1522 Knots: knots,
1523 RepoInfo: f.RepoInfo(user),
1524 })
1525
1526 case http.MethodPost:
1527
1528 knot := r.FormValue("knot")
1529 if knot == "" {
1530 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1531 return
1532 }
1533
1534 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1535 if err != nil || !ok {
1536 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1537 return
1538 }
1539
1540 forkName := fmt.Sprintf("%s", f.Name)
1541
1542 // this check is *only* to see if the forked repo name already exists
1543 // in the user's account.
1544 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1545 if err != nil {
1546 if errors.Is(err, sql.ErrNoRows) {
1547 // no existing repo with this name found, we can use the name as is
1548 } else {
1549 log.Println("error fetching existing repo from db", err)
1550 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1551 return
1552 }
1553 } else if existingRepo != nil {
1554 // repo with this name already exists, append random string
1555 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1556 }
1557 secret, err := db.GetRegistrationKey(rp.db, knot)
1558 if err != nil {
1559 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1560 return
1561 }
1562
1563 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1564 if err != nil {
1565 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1566 return
1567 }
1568
1569 var uri string
1570 if rp.config.Core.Dev {
1571 uri = "http"
1572 } else {
1573 uri = "https"
1574 }
1575 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1576 sourceAt := f.RepoAt().String()
1577
1578 rkey := tid.TID()
1579 repo := &db.Repo{
1580 Did: user.Did,
1581 Name: forkName,
1582 Knot: knot,
1583 Rkey: rkey,
1584 Source: sourceAt,
1585 }
1586
1587 tx, err := rp.db.BeginTx(r.Context(), nil)
1588 if err != nil {
1589 log.Println(err)
1590 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1591 return
1592 }
1593 defer func() {
1594 tx.Rollback()
1595 err = rp.enforcer.E.LoadPolicy()
1596 if err != nil {
1597 log.Println("failed to rollback policies")
1598 }
1599 }()
1600
1601 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1602 if err != nil {
1603 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1604 return
1605 }
1606
1607 switch resp.StatusCode {
1608 case http.StatusConflict:
1609 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1610 return
1611 case http.StatusInternalServerError:
1612 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1613 case http.StatusNoContent:
1614 // continue
1615 }
1616
1617 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1618 if err != nil {
1619 log.Println("failed to get authorized client", err)
1620 rp.pages.Notice(w, "repo", "Failed to create repository.")
1621 return
1622 }
1623
1624 createdAt := time.Now().Format(time.RFC3339)
1625 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1626 Collection: tangled.RepoNSID,
1627 Repo: user.Did,
1628 Rkey: rkey,
1629 Record: &lexutil.LexiconTypeDecoder{
1630 Val: &tangled.Repo{
1631 Knot: repo.Knot,
1632 Name: repo.Name,
1633 CreatedAt: createdAt,
1634 Owner: user.Did,
1635 Source: &sourceAt,
1636 }},
1637 })
1638 if err != nil {
1639 log.Printf("failed to create record: %s", err)
1640 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1641 return
1642 }
1643 log.Println("created repo record: ", atresp.Uri)
1644
1645 err = db.AddRepo(tx, repo)
1646 if err != nil {
1647 log.Println(err)
1648 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1649 return
1650 }
1651
1652 // acls
1653 p, _ := securejoin.SecureJoin(user.Did, forkName)
1654 err = rp.enforcer.AddRepo(user.Did, knot, p)
1655 if err != nil {
1656 log.Println(err)
1657 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1658 return
1659 }
1660
1661 err = tx.Commit()
1662 if err != nil {
1663 log.Println("failed to commit changes", err)
1664 http.Error(w, err.Error(), http.StatusInternalServerError)
1665 return
1666 }
1667
1668 err = rp.enforcer.E.SavePolicy()
1669 if err != nil {
1670 log.Println("failed to update ACLs", err)
1671 http.Error(w, err.Error(), http.StatusInternalServerError)
1672 return
1673 }
1674
1675 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1676 return
1677 }
1678}
1679
1680func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1681 user := rp.oauth.GetUser(r)
1682 f, err := rp.repoResolver.Resolve(r)
1683 if err != nil {
1684 log.Println("failed to get repo and knot", err)
1685 return
1686 }
1687
1688 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1689 if err != nil {
1690 log.Printf("failed to create unsigned client for %s", f.Knot)
1691 rp.pages.Error503(w)
1692 return
1693 }
1694
1695 result, err := us.Branches(f.OwnerDid(), f.Name)
1696 if err != nil {
1697 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1698 log.Println("failed to reach knotserver", err)
1699 return
1700 }
1701 branches := result.Branches
1702
1703 sortBranches(branches)
1704
1705 var defaultBranch string
1706 for _, b := range branches {
1707 if b.IsDefault {
1708 defaultBranch = b.Name
1709 }
1710 }
1711
1712 base := defaultBranch
1713 head := defaultBranch
1714
1715 params := r.URL.Query()
1716 queryBase := params.Get("base")
1717 queryHead := params.Get("head")
1718 if queryBase != "" {
1719 base = queryBase
1720 }
1721 if queryHead != "" {
1722 head = queryHead
1723 }
1724
1725 tags, err := us.Tags(f.OwnerDid(), f.Name)
1726 if err != nil {
1727 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1728 log.Println("failed to reach knotserver", err)
1729 return
1730 }
1731
1732 repoinfo := f.RepoInfo(user)
1733
1734 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1735 LoggedInUser: user,
1736 RepoInfo: repoinfo,
1737 Branches: branches,
1738 Tags: tags.Tags,
1739 Base: base,
1740 Head: head,
1741 })
1742}
1743
1744func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1745 user := rp.oauth.GetUser(r)
1746 f, err := rp.repoResolver.Resolve(r)
1747 if err != nil {
1748 log.Println("failed to get repo and knot", err)
1749 return
1750 }
1751
1752 var diffOpts types.DiffOpts
1753 if d := r.URL.Query().Get("diff"); d == "split" {
1754 diffOpts.Split = true
1755 }
1756
1757 // if user is navigating to one of
1758 // /compare/{base}/{head}
1759 // /compare/{base}...{head}
1760 base := chi.URLParam(r, "base")
1761 head := chi.URLParam(r, "head")
1762 if base == "" && head == "" {
1763 rest := chi.URLParam(r, "*") // master...feature/xyz
1764 parts := strings.SplitN(rest, "...", 2)
1765 if len(parts) == 2 {
1766 base = parts[0]
1767 head = parts[1]
1768 }
1769 }
1770
1771 base, _ = url.PathUnescape(base)
1772 head, _ = url.PathUnescape(head)
1773
1774 if base == "" || head == "" {
1775 log.Printf("invalid comparison")
1776 rp.pages.Error404(w)
1777 return
1778 }
1779
1780 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1781 if err != nil {
1782 log.Printf("failed to create unsigned client for %s", f.Knot)
1783 rp.pages.Error503(w)
1784 return
1785 }
1786
1787 branches, err := us.Branches(f.OwnerDid(), f.Name)
1788 if err != nil {
1789 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1790 log.Println("failed to reach knotserver", err)
1791 return
1792 }
1793
1794 tags, err := us.Tags(f.OwnerDid(), f.Name)
1795 if err != nil {
1796 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1797 log.Println("failed to reach knotserver", err)
1798 return
1799 }
1800
1801 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1802 if err != nil {
1803 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1804 log.Println("failed to compare", err)
1805 return
1806 }
1807 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1808
1809 repoinfo := f.RepoInfo(user)
1810
1811 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1812 LoggedInUser: user,
1813 RepoInfo: repoinfo,
1814 Branches: branches.Branches,
1815 Tags: tags.Tags,
1816 Base: base,
1817 Head: head,
1818 Diff: &diff,
1819 DiffOpts: diffOpts,
1820 })
1821
1822}