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 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
738 resp, err := http.Get(blobURL)
739 if err != nil {
740 log.Println("failed to reach knotserver:", err)
741 rp.pages.Error503(w)
742 return
743 }
744 defer resp.Body.Close()
745
746 if resp.StatusCode != http.StatusOK {
747 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
748 w.WriteHeader(resp.StatusCode)
749 _, _ = io.Copy(w, resp.Body)
750 return
751 }
752
753 contentType := resp.Header.Get("Content-Type")
754 body, err := io.ReadAll(resp.Body)
755 if err != nil {
756 log.Printf("error reading response body from knotserver: %v", err)
757 w.WriteHeader(http.StatusInternalServerError)
758 return
759 }
760
761 if strings.Contains(contentType, "text/plain") {
762 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
763 w.Write(body)
764 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
765 w.Header().Set("Content-Type", contentType)
766 w.Write(body)
767 } else {
768 w.WriteHeader(http.StatusUnsupportedMediaType)
769 w.Write([]byte("unsupported content type"))
770 return
771 }
772}
773
774// modify the spindle configured for this repo
775func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
776 user := rp.oauth.GetUser(r)
777 l := rp.logger.With("handler", "EditSpindle")
778 l = l.With("did", user.Did)
779 l = l.With("handle", user.Handle)
780
781 errorId := "operation-error"
782 fail := func(msg string, err error) {
783 l.Error(msg, "err", err)
784 rp.pages.Notice(w, errorId, msg)
785 }
786
787 f, err := rp.repoResolver.Resolve(r)
788 if err != nil {
789 fail("Failed to resolve repo. Try again later", err)
790 return
791 }
792
793 repoAt := f.RepoAt()
794 rkey := repoAt.RecordKey().String()
795 if rkey == "" {
796 fail("Failed to resolve repo. Try again later", err)
797 return
798 }
799
800 newSpindle := r.FormValue("spindle")
801 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
802 client, err := rp.oauth.AuthorizedClient(r)
803 if err != nil {
804 fail("Failed to authorize. Try again later.", err)
805 return
806 }
807
808 if !removingSpindle {
809 // ensure that this is a valid spindle for this user
810 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
811 if err != nil {
812 fail("Failed to find spindles. Try again later.", err)
813 return
814 }
815
816 if !slices.Contains(validSpindles, newSpindle) {
817 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
818 return
819 }
820 }
821
822 spindlePtr := &newSpindle
823 if removingSpindle {
824 spindlePtr = nil
825 }
826
827 // optimistic update
828 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
829 if err != nil {
830 fail("Failed to update spindle. Try again later.", err)
831 return
832 }
833
834 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
835 if err != nil {
836 fail("Failed to update spindle, no record found on PDS.", err)
837 return
838 }
839 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
840 Collection: tangled.RepoNSID,
841 Repo: user.Did,
842 Rkey: rkey,
843 SwapRecord: ex.Cid,
844 Record: &lexutil.LexiconTypeDecoder{
845 Val: &tangled.Repo{
846 Knot: f.Knot,
847 Name: f.Name,
848 Owner: user.Did,
849 CreatedAt: f.Created.Format(time.RFC3339),
850 Description: &f.Description,
851 Spindle: spindlePtr,
852 },
853 },
854 })
855
856 if err != nil {
857 fail("Failed to update spindle, unable to save to PDS.", err)
858 return
859 }
860
861 if !removingSpindle {
862 // add this spindle to spindle stream
863 rp.spindlestream.AddSource(
864 context.Background(),
865 eventconsumer.NewSpindleSource(newSpindle),
866 )
867 }
868
869 rp.pages.HxRefresh(w)
870}
871
872func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
873 user := rp.oauth.GetUser(r)
874 l := rp.logger.With("handler", "AddCollaborator")
875 l = l.With("did", user.Did)
876 l = l.With("handle", user.Handle)
877
878 f, err := rp.repoResolver.Resolve(r)
879 if err != nil {
880 l.Error("failed to get repo and knot", "err", err)
881 return
882 }
883
884 errorId := "add-collaborator-error"
885 fail := func(msg string, err error) {
886 l.Error(msg, "err", err)
887 rp.pages.Notice(w, errorId, msg)
888 }
889
890 collaborator := r.FormValue("collaborator")
891 if collaborator == "" {
892 fail("Invalid form.", nil)
893 return
894 }
895
896 // remove a single leading `@`, to make @handle work with ResolveIdent
897 collaborator = strings.TrimPrefix(collaborator, "@")
898
899 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
900 if err != nil {
901 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
902 return
903 }
904
905 if collaboratorIdent.DID.String() == user.Did {
906 fail("You seem to be adding yourself as a collaborator.", nil)
907 return
908 }
909 l = l.With("collaborator", collaboratorIdent.Handle)
910 l = l.With("knot", f.Knot)
911
912 // announce this relation into the firehose, store into owners' pds
913 client, err := rp.oauth.AuthorizedClient(r)
914 if err != nil {
915 fail("Failed to write to PDS.", err)
916 return
917 }
918
919 // emit a record
920 currentUser := rp.oauth.GetUser(r)
921 rkey := tid.TID()
922 createdAt := time.Now()
923 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
924 Collection: tangled.RepoCollaboratorNSID,
925 Repo: currentUser.Did,
926 Rkey: rkey,
927 Record: &lexutil.LexiconTypeDecoder{
928 Val: &tangled.RepoCollaborator{
929 Subject: collaboratorIdent.DID.String(),
930 Repo: string(f.RepoAt()),
931 CreatedAt: createdAt.Format(time.RFC3339),
932 }},
933 })
934 // invalid record
935 if err != nil {
936 fail("Failed to write record to PDS.", err)
937 return
938 }
939 l = l.With("at-uri", resp.Uri)
940 l.Info("wrote record to PDS")
941
942 l.Info("adding to knot")
943 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
944 if err != nil {
945 fail("Failed to add to knot.", err)
946 return
947 }
948
949 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
950 if err != nil {
951 fail("Failed to add to knot.", err)
952 return
953 }
954
955 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
956 if err != nil {
957 fail("Knot was unreachable.", err)
958 return
959 }
960
961 if ksResp.StatusCode != http.StatusNoContent {
962 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
963 return
964 }
965
966 tx, err := rp.db.BeginTx(r.Context(), nil)
967 if err != nil {
968 fail("Failed to add collaborator.", err)
969 return
970 }
971 defer func() {
972 tx.Rollback()
973 err = rp.enforcer.E.LoadPolicy()
974 if err != nil {
975 fail("Failed to add collaborator.", err)
976 }
977 }()
978
979 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
980 if err != nil {
981 fail("Failed to add collaborator permissions.", err)
982 return
983 }
984
985 err = db.AddCollaborator(rp.db, db.Collaborator{
986 Did: syntax.DID(currentUser.Did),
987 Rkey: rkey,
988 SubjectDid: collaboratorIdent.DID,
989 RepoAt: f.RepoAt(),
990 Created: createdAt,
991 })
992 if err != nil {
993 fail("Failed to add collaborator.", err)
994 return
995 }
996
997 err = tx.Commit()
998 if err != nil {
999 fail("Failed to add collaborator.", err)
1000 return
1001 }
1002
1003 err = rp.enforcer.E.SavePolicy()
1004 if err != nil {
1005 fail("Failed to update collaborator permissions.", err)
1006 return
1007 }
1008
1009 rp.pages.HxRefresh(w)
1010}
1011
1012func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1013 user := rp.oauth.GetUser(r)
1014
1015 f, err := rp.repoResolver.Resolve(r)
1016 if err != nil {
1017 log.Println("failed to get repo and knot", err)
1018 return
1019 }
1020
1021 // remove record from pds
1022 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1023 if err != nil {
1024 log.Println("failed to get authorized client", err)
1025 return
1026 }
1027 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1028 Collection: tangled.RepoNSID,
1029 Repo: user.Did,
1030 Rkey: f.Rkey,
1031 })
1032 if err != nil {
1033 log.Printf("failed to delete record: %s", err)
1034 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
1035 return
1036 }
1037 log.Println("removed repo record ", f.RepoAt().String())
1038
1039 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1040 if err != nil {
1041 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1042 return
1043 }
1044
1045 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1046 if err != nil {
1047 log.Println("failed to create client to ", f.Knot)
1048 return
1049 }
1050
1051 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
1052 if err != nil {
1053 log.Printf("failed to make request to %s: %s", f.Knot, err)
1054 return
1055 }
1056
1057 if ksResp.StatusCode != http.StatusNoContent {
1058 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
1059 } else {
1060 log.Println("removed repo from knot ", f.Knot)
1061 }
1062
1063 tx, err := rp.db.BeginTx(r.Context(), nil)
1064 if err != nil {
1065 log.Println("failed to start tx")
1066 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1067 return
1068 }
1069 defer func() {
1070 tx.Rollback()
1071 err = rp.enforcer.E.LoadPolicy()
1072 if err != nil {
1073 log.Println("failed to rollback policies")
1074 }
1075 }()
1076
1077 // remove collaborator RBAC
1078 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1079 if err != nil {
1080 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1081 return
1082 }
1083 for _, c := range repoCollaborators {
1084 did := c[0]
1085 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1086 }
1087 log.Println("removed collaborators")
1088
1089 // remove repo RBAC
1090 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1091 if err != nil {
1092 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1093 return
1094 }
1095
1096 // remove repo from db
1097 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1098 if err != nil {
1099 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1100 return
1101 }
1102 log.Println("removed repo from db")
1103
1104 err = tx.Commit()
1105 if err != nil {
1106 log.Println("failed to commit changes", err)
1107 http.Error(w, err.Error(), http.StatusInternalServerError)
1108 return
1109 }
1110
1111 err = rp.enforcer.E.SavePolicy()
1112 if err != nil {
1113 log.Println("failed to update ACLs", err)
1114 http.Error(w, err.Error(), http.StatusInternalServerError)
1115 return
1116 }
1117
1118 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1119}
1120
1121func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1122 f, err := rp.repoResolver.Resolve(r)
1123 if err != nil {
1124 log.Println("failed to get repo and knot", err)
1125 return
1126 }
1127
1128 branch := r.FormValue("branch")
1129 if branch == "" {
1130 http.Error(w, "malformed form", http.StatusBadRequest)
1131 return
1132 }
1133
1134 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1135 if err != nil {
1136 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1137 return
1138 }
1139
1140 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1141 if err != nil {
1142 log.Println("failed to create client to ", f.Knot)
1143 return
1144 }
1145
1146 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
1147 if err != nil {
1148 log.Printf("failed to make request to %s: %s", f.Knot, err)
1149 return
1150 }
1151
1152 if ksResp.StatusCode != http.StatusNoContent {
1153 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
1154 return
1155 }
1156
1157 w.Write(fmt.Append(nil, "default branch set to: ", branch))
1158}
1159
1160func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1161 user := rp.oauth.GetUser(r)
1162 l := rp.logger.With("handler", "Secrets")
1163 l = l.With("handle", user.Handle)
1164 l = l.With("did", user.Did)
1165
1166 f, err := rp.repoResolver.Resolve(r)
1167 if err != nil {
1168 log.Println("failed to get repo and knot", err)
1169 return
1170 }
1171
1172 if f.Spindle == "" {
1173 log.Println("empty spindle cannot add/rm secret", err)
1174 return
1175 }
1176
1177 lxm := tangled.RepoAddSecretNSID
1178 if r.Method == http.MethodDelete {
1179 lxm = tangled.RepoRemoveSecretNSID
1180 }
1181
1182 spindleClient, err := rp.oauth.ServiceClient(
1183 r,
1184 oauth.WithService(f.Spindle),
1185 oauth.WithLxm(lxm),
1186 oauth.WithExp(60),
1187 oauth.WithDev(rp.config.Core.Dev),
1188 )
1189 if err != nil {
1190 log.Println("failed to create spindle client", err)
1191 return
1192 }
1193
1194 key := r.FormValue("key")
1195 if key == "" {
1196 w.WriteHeader(http.StatusBadRequest)
1197 return
1198 }
1199
1200 switch r.Method {
1201 case http.MethodPut:
1202 errorId := "add-secret-error"
1203
1204 value := r.FormValue("value")
1205 if value == "" {
1206 w.WriteHeader(http.StatusBadRequest)
1207 return
1208 }
1209
1210 err = tangled.RepoAddSecret(
1211 r.Context(),
1212 spindleClient,
1213 &tangled.RepoAddSecret_Input{
1214 Repo: f.RepoAt().String(),
1215 Key: key,
1216 Value: value,
1217 },
1218 )
1219 if err != nil {
1220 l.Error("Failed to add secret.", "err", err)
1221 rp.pages.Notice(w, errorId, "Failed to add secret.")
1222 return
1223 }
1224
1225 case http.MethodDelete:
1226 errorId := "operation-error"
1227
1228 err = tangled.RepoRemoveSecret(
1229 r.Context(),
1230 spindleClient,
1231 &tangled.RepoRemoveSecret_Input{
1232 Repo: f.RepoAt().String(),
1233 Key: key,
1234 },
1235 )
1236 if err != nil {
1237 l.Error("Failed to delete secret.", "err", err)
1238 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1239 return
1240 }
1241 }
1242
1243 rp.pages.HxRefresh(w)
1244}
1245
1246type tab = map[string]any
1247
1248var (
1249 // would be great to have ordered maps right about now
1250 settingsTabs []tab = []tab{
1251 {"Name": "general", "Icon": "sliders-horizontal"},
1252 {"Name": "access", "Icon": "users"},
1253 {"Name": "pipelines", "Icon": "layers-2"},
1254 }
1255)
1256
1257func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1258 tabVal := r.URL.Query().Get("tab")
1259 if tabVal == "" {
1260 tabVal = "general"
1261 }
1262
1263 switch tabVal {
1264 case "general":
1265 rp.generalSettings(w, r)
1266
1267 case "access":
1268 rp.accessSettings(w, r)
1269
1270 case "pipelines":
1271 rp.pipelineSettings(w, r)
1272 }
1273
1274 // user := rp.oauth.GetUser(r)
1275 // repoCollaborators, err := f.Collaborators(r.Context())
1276 // if err != nil {
1277 // log.Println("failed to get collaborators", err)
1278 // }
1279
1280 // isCollaboratorInviteAllowed := false
1281 // if user != nil {
1282 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1283 // if err == nil && ok {
1284 // isCollaboratorInviteAllowed = true
1285 // }
1286 // }
1287
1288 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1289 // if err != nil {
1290 // log.Println("failed to create unsigned client", err)
1291 // return
1292 // }
1293
1294 // result, err := us.Branches(f.OwnerDid(), f.Name)
1295 // if err != nil {
1296 // log.Println("failed to reach knotserver", err)
1297 // return
1298 // }
1299
1300 // // all spindles that this user is a member of
1301 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1302 // if err != nil {
1303 // log.Println("failed to fetch spindles", err)
1304 // return
1305 // }
1306
1307 // var secrets []*tangled.RepoListSecrets_Secret
1308 // if f.Spindle != "" {
1309 // if spindleClient, err := rp.oauth.ServiceClient(
1310 // r,
1311 // oauth.WithService(f.Spindle),
1312 // oauth.WithLxm(tangled.RepoListSecretsNSID),
1313 // oauth.WithDev(rp.config.Core.Dev),
1314 // ); err != nil {
1315 // log.Println("failed to create spindle client", err)
1316 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1317 // log.Println("failed to fetch secrets", err)
1318 // } else {
1319 // secrets = resp.Secrets
1320 // }
1321 // }
1322
1323 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1324 // LoggedInUser: user,
1325 // RepoInfo: f.RepoInfo(user),
1326 // Collaborators: repoCollaborators,
1327 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1328 // Branches: result.Branches,
1329 // Spindles: spindles,
1330 // CurrentSpindle: f.Spindle,
1331 // Secrets: secrets,
1332 // })
1333}
1334
1335func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1336 f, err := rp.repoResolver.Resolve(r)
1337 user := rp.oauth.GetUser(r)
1338
1339 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1340 if err != nil {
1341 log.Println("failed to create unsigned client", err)
1342 return
1343 }
1344
1345 result, err := us.Branches(f.OwnerDid(), f.Name)
1346 if err != nil {
1347 log.Println("failed to reach knotserver", err)
1348 return
1349 }
1350
1351 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1352 LoggedInUser: user,
1353 RepoInfo: f.RepoInfo(user),
1354 Branches: result.Branches,
1355 Tabs: settingsTabs,
1356 Tab: "general",
1357 })
1358}
1359
1360func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1361 f, err := rp.repoResolver.Resolve(r)
1362 user := rp.oauth.GetUser(r)
1363
1364 repoCollaborators, err := f.Collaborators(r.Context())
1365 if err != nil {
1366 log.Println("failed to get collaborators", err)
1367 }
1368
1369 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1370 LoggedInUser: user,
1371 RepoInfo: f.RepoInfo(user),
1372 Tabs: settingsTabs,
1373 Tab: "access",
1374 Collaborators: repoCollaborators,
1375 })
1376}
1377
1378func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1379 f, err := rp.repoResolver.Resolve(r)
1380 user := rp.oauth.GetUser(r)
1381
1382 // all spindles that the repo owner is a member of
1383 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1384 if err != nil {
1385 log.Println("failed to fetch spindles", err)
1386 return
1387 }
1388
1389 var secrets []*tangled.RepoListSecrets_Secret
1390 if f.Spindle != "" {
1391 if spindleClient, err := rp.oauth.ServiceClient(
1392 r,
1393 oauth.WithService(f.Spindle),
1394 oauth.WithLxm(tangled.RepoListSecretsNSID),
1395 oauth.WithExp(60),
1396 oauth.WithDev(rp.config.Core.Dev),
1397 ); err != nil {
1398 log.Println("failed to create spindle client", err)
1399 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1400 log.Println("failed to fetch secrets", err)
1401 } else {
1402 secrets = resp.Secrets
1403 }
1404 }
1405
1406 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1407 return strings.Compare(a.Key, b.Key)
1408 })
1409
1410 var dids []string
1411 for _, s := range secrets {
1412 dids = append(dids, s.CreatedBy)
1413 }
1414 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1415
1416 // convert to a more manageable form
1417 var niceSecret []map[string]any
1418 for id, s := range secrets {
1419 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1420 niceSecret = append(niceSecret, map[string]any{
1421 "Id": id,
1422 "Key": s.Key,
1423 "CreatedAt": when,
1424 "CreatedBy": resolvedIdents[id].Handle.String(),
1425 })
1426 }
1427
1428 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1429 LoggedInUser: user,
1430 RepoInfo: f.RepoInfo(user),
1431 Tabs: settingsTabs,
1432 Tab: "pipelines",
1433 Spindles: spindles,
1434 CurrentSpindle: f.Spindle,
1435 Secrets: niceSecret,
1436 })
1437}
1438
1439func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1440 ref := chi.URLParam(r, "ref")
1441
1442 user := rp.oauth.GetUser(r)
1443 f, err := rp.repoResolver.Resolve(r)
1444 if err != nil {
1445 log.Printf("failed to resolve source repo: %v", err)
1446 return
1447 }
1448
1449 switch r.Method {
1450 case http.MethodPost:
1451 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1452 if err != nil {
1453 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1454 return
1455 }
1456
1457 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1458 if err != nil {
1459 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1460 return
1461 }
1462
1463 var uri string
1464 if rp.config.Core.Dev {
1465 uri = "http"
1466 } else {
1467 uri = "https"
1468 }
1469 forkName := fmt.Sprintf("%s", f.Name)
1470 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1471
1472 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1473 if err != nil {
1474 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1475 return
1476 }
1477
1478 rp.pages.HxRefresh(w)
1479 return
1480 }
1481}
1482
1483func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1484 user := rp.oauth.GetUser(r)
1485 f, err := rp.repoResolver.Resolve(r)
1486 if err != nil {
1487 log.Printf("failed to resolve source repo: %v", err)
1488 return
1489 }
1490
1491 switch r.Method {
1492 case http.MethodGet:
1493 user := rp.oauth.GetUser(r)
1494 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1495 if err != nil {
1496 rp.pages.Notice(w, "repo", "Invalid user account.")
1497 return
1498 }
1499
1500 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1501 LoggedInUser: user,
1502 Knots: knots,
1503 RepoInfo: f.RepoInfo(user),
1504 })
1505
1506 case http.MethodPost:
1507
1508 knot := r.FormValue("knot")
1509 if knot == "" {
1510 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1511 return
1512 }
1513
1514 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1515 if err != nil || !ok {
1516 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1517 return
1518 }
1519
1520 forkName := fmt.Sprintf("%s", f.Name)
1521
1522 // this check is *only* to see if the forked repo name already exists
1523 // in the user's account.
1524 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1525 if err != nil {
1526 if errors.Is(err, sql.ErrNoRows) {
1527 // no existing repo with this name found, we can use the name as is
1528 } else {
1529 log.Println("error fetching existing repo from db", err)
1530 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1531 return
1532 }
1533 } else if existingRepo != nil {
1534 // repo with this name already exists, append random string
1535 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1536 }
1537 secret, err := db.GetRegistrationKey(rp.db, knot)
1538 if err != nil {
1539 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1540 return
1541 }
1542
1543 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1544 if err != nil {
1545 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1546 return
1547 }
1548
1549 var uri string
1550 if rp.config.Core.Dev {
1551 uri = "http"
1552 } else {
1553 uri = "https"
1554 }
1555 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1556 sourceAt := f.RepoAt().String()
1557
1558 rkey := tid.TID()
1559 repo := &db.Repo{
1560 Did: user.Did,
1561 Name: forkName,
1562 Knot: knot,
1563 Rkey: rkey,
1564 Source: sourceAt,
1565 }
1566
1567 tx, err := rp.db.BeginTx(r.Context(), nil)
1568 if err != nil {
1569 log.Println(err)
1570 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1571 return
1572 }
1573 defer func() {
1574 tx.Rollback()
1575 err = rp.enforcer.E.LoadPolicy()
1576 if err != nil {
1577 log.Println("failed to rollback policies")
1578 }
1579 }()
1580
1581 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1582 if err != nil {
1583 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1584 return
1585 }
1586
1587 switch resp.StatusCode {
1588 case http.StatusConflict:
1589 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1590 return
1591 case http.StatusInternalServerError:
1592 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1593 case http.StatusNoContent:
1594 // continue
1595 }
1596
1597 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1598 if err != nil {
1599 log.Println("failed to get authorized client", err)
1600 rp.pages.Notice(w, "repo", "Failed to create repository.")
1601 return
1602 }
1603
1604 createdAt := time.Now().Format(time.RFC3339)
1605 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1606 Collection: tangled.RepoNSID,
1607 Repo: user.Did,
1608 Rkey: rkey,
1609 Record: &lexutil.LexiconTypeDecoder{
1610 Val: &tangled.Repo{
1611 Knot: repo.Knot,
1612 Name: repo.Name,
1613 CreatedAt: createdAt,
1614 Owner: user.Did,
1615 Source: &sourceAt,
1616 }},
1617 })
1618 if err != nil {
1619 log.Printf("failed to create record: %s", err)
1620 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1621 return
1622 }
1623 log.Println("created repo record: ", atresp.Uri)
1624
1625 err = db.AddRepo(tx, repo)
1626 if err != nil {
1627 log.Println(err)
1628 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1629 return
1630 }
1631
1632 // acls
1633 p, _ := securejoin.SecureJoin(user.Did, forkName)
1634 err = rp.enforcer.AddRepo(user.Did, knot, p)
1635 if err != nil {
1636 log.Println(err)
1637 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1638 return
1639 }
1640
1641 err = tx.Commit()
1642 if err != nil {
1643 log.Println("failed to commit changes", err)
1644 http.Error(w, err.Error(), http.StatusInternalServerError)
1645 return
1646 }
1647
1648 err = rp.enforcer.E.SavePolicy()
1649 if err != nil {
1650 log.Println("failed to update ACLs", err)
1651 http.Error(w, err.Error(), http.StatusInternalServerError)
1652 return
1653 }
1654
1655 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1656 return
1657 }
1658}
1659
1660func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1661 user := rp.oauth.GetUser(r)
1662 f, err := rp.repoResolver.Resolve(r)
1663 if err != nil {
1664 log.Println("failed to get repo and knot", err)
1665 return
1666 }
1667
1668 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1669 if err != nil {
1670 log.Printf("failed to create unsigned client for %s", f.Knot)
1671 rp.pages.Error503(w)
1672 return
1673 }
1674
1675 result, err := us.Branches(f.OwnerDid(), f.Name)
1676 if err != nil {
1677 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1678 log.Println("failed to reach knotserver", err)
1679 return
1680 }
1681 branches := result.Branches
1682
1683 sortBranches(branches)
1684
1685 var defaultBranch string
1686 for _, b := range branches {
1687 if b.IsDefault {
1688 defaultBranch = b.Name
1689 }
1690 }
1691
1692 base := defaultBranch
1693 head := defaultBranch
1694
1695 params := r.URL.Query()
1696 queryBase := params.Get("base")
1697 queryHead := params.Get("head")
1698 if queryBase != "" {
1699 base = queryBase
1700 }
1701 if queryHead != "" {
1702 head = queryHead
1703 }
1704
1705 tags, err := us.Tags(f.OwnerDid(), f.Name)
1706 if err != nil {
1707 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1708 log.Println("failed to reach knotserver", err)
1709 return
1710 }
1711
1712 repoinfo := f.RepoInfo(user)
1713
1714 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1715 LoggedInUser: user,
1716 RepoInfo: repoinfo,
1717 Branches: branches,
1718 Tags: tags.Tags,
1719 Base: base,
1720 Head: head,
1721 })
1722}
1723
1724func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1725 user := rp.oauth.GetUser(r)
1726 f, err := rp.repoResolver.Resolve(r)
1727 if err != nil {
1728 log.Println("failed to get repo and knot", err)
1729 return
1730 }
1731
1732 var diffOpts types.DiffOpts
1733 if d := r.URL.Query().Get("diff"); d == "split" {
1734 diffOpts.Split = true
1735 }
1736
1737 // if user is navigating to one of
1738 // /compare/{base}/{head}
1739 // /compare/{base}...{head}
1740 base := chi.URLParam(r, "base")
1741 head := chi.URLParam(r, "head")
1742 if base == "" && head == "" {
1743 rest := chi.URLParam(r, "*") // master...feature/xyz
1744 parts := strings.SplitN(rest, "...", 2)
1745 if len(parts) == 2 {
1746 base = parts[0]
1747 head = parts[1]
1748 }
1749 }
1750
1751 base, _ = url.PathUnescape(base)
1752 head, _ = url.PathUnescape(head)
1753
1754 if base == "" || head == "" {
1755 log.Printf("invalid comparison")
1756 rp.pages.Error404(w)
1757 return
1758 }
1759
1760 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1761 if err != nil {
1762 log.Printf("failed to create unsigned client for %s", f.Knot)
1763 rp.pages.Error503(w)
1764 return
1765 }
1766
1767 branches, err := us.Branches(f.OwnerDid(), f.Name)
1768 if err != nil {
1769 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1770 log.Println("failed to reach knotserver", err)
1771 return
1772 }
1773
1774 tags, err := us.Tags(f.OwnerDid(), f.Name)
1775 if err != nil {
1776 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1777 log.Println("failed to reach knotserver", err)
1778 return
1779 }
1780
1781 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1782 if err != nil {
1783 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1784 log.Println("failed to compare", err)
1785 return
1786 }
1787 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1788
1789 repoinfo := f.RepoInfo(user)
1790
1791 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1792 LoggedInUser: user,
1793 RepoInfo: repoinfo,
1794 Branches: branches.Branches,
1795 Tags: tags.Tags,
1796 Base: base,
1797 Head: head,
1798 Diff: &diff,
1799 DiffOpts: diffOpts,
1800 })
1801
1802}