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