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