this repo has no description
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "path/filepath"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19
20 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 CreatedAt: f.Created.Format(time.RFC3339),
317 Description: &newDescription,
318 Spindle: &f.Spindle,
319 },
320 },
321 })
322
323 if err != nil {
324 log.Println("failed to perferom update-description query", err)
325 // failed to get record
326 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
327 return
328 }
329
330 newRepoInfo := f.RepoInfo(user)
331 newRepoInfo.Description = newDescription
332
333 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
334 RepoInfo: newRepoInfo,
335 })
336 return
337 }
338}
339
340func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
341 f, err := rp.repoResolver.Resolve(r)
342 if err != nil {
343 log.Println("failed to fully resolve repo", err)
344 return
345 }
346 ref := chi.URLParam(r, "ref")
347 ref, _ = url.PathUnescape(ref)
348
349 var diffOpts types.DiffOpts
350 if d := r.URL.Query().Get("diff"); d == "split" {
351 diffOpts.Split = true
352 }
353
354 if !plumbing.IsHash(ref) {
355 rp.pages.Error404(w)
356 return
357 }
358
359 scheme := "http"
360 if !rp.config.Core.Dev {
361 scheme = "https"
362 }
363 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
364 xrpcc := &indigoxrpc.Client{
365 Host: host,
366 }
367
368 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
369 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
370 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
371 log.Println("failed to call XRPC repo.diff", xrpcerr)
372 rp.pages.Error503(w)
373 return
374 }
375
376 var result types.RepoCommitResponse
377 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
378 log.Println("failed to decode XRPC response", err)
379 rp.pages.Error503(w)
380 return
381 }
382
383 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
384 if err != nil {
385 log.Println("failed to get email to did mapping:", err)
386 }
387
388 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
389 if err != nil {
390 log.Println(err)
391 }
392
393 user := rp.oauth.GetUser(r)
394 repoInfo := f.RepoInfo(user)
395 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
396 if err != nil {
397 log.Println(err)
398 // non-fatal
399 }
400 var pipeline *db.Pipeline
401 if p, ok := pipelines[result.Diff.Commit.This]; ok {
402 pipeline = &p
403 }
404
405 rp.pages.RepoCommit(w, pages.RepoCommitParams{
406 LoggedInUser: user,
407 RepoInfo: f.RepoInfo(user),
408 RepoCommitResponse: result,
409 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
410 VerifiedCommit: vc,
411 Pipeline: pipeline,
412 DiffOpts: diffOpts,
413 })
414}
415
416func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
417 f, err := rp.repoResolver.Resolve(r)
418 if err != nil {
419 log.Println("failed to fully resolve repo", err)
420 return
421 }
422
423 ref := chi.URLParam(r, "ref")
424 ref, _ = url.PathUnescape(ref)
425
426 // if the tree path has a trailing slash, let's strip it
427 // so we don't 404
428 treePath := chi.URLParam(r, "*")
429 treePath, _ = url.PathUnescape(treePath)
430 treePath = strings.TrimSuffix(treePath, "/")
431
432 scheme := "http"
433 if !rp.config.Core.Dev {
434 scheme = "https"
435 }
436 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
437 xrpcc := &indigoxrpc.Client{
438 Host: host,
439 }
440
441 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
442 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
443 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
444 log.Println("failed to call XRPC repo.tree", xrpcerr)
445 rp.pages.Error503(w)
446 return
447 }
448
449 // Convert XRPC response to internal types.RepoTreeResponse
450 files := make([]types.NiceTree, len(xrpcResp.Files))
451 for i, xrpcFile := range xrpcResp.Files {
452 file := types.NiceTree{
453 Name: xrpcFile.Name,
454 Mode: xrpcFile.Mode,
455 Size: int64(xrpcFile.Size),
456 IsFile: xrpcFile.Is_file,
457 IsSubtree: xrpcFile.Is_subtree,
458 }
459
460 // Convert last commit info if present
461 if xrpcFile.Last_commit != nil {
462 commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
463 file.LastCommit = &types.LastCommitInfo{
464 Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
465 Message: xrpcFile.Last_commit.Message,
466 When: commitWhen,
467 }
468 }
469
470 files[i] = file
471 }
472
473 result := types.RepoTreeResponse{
474 Ref: xrpcResp.Ref,
475 Files: files,
476 }
477
478 if xrpcResp.Parent != nil {
479 result.Parent = *xrpcResp.Parent
480 }
481 if xrpcResp.Dotdot != nil {
482 result.DotDot = *xrpcResp.Dotdot
483 }
484
485 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
486 // so we can safely redirect to the "parent" (which is the same file).
487 if len(result.Files) == 0 && result.Parent == treePath {
488 redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
489 http.Redirect(w, r, redirectTo, http.StatusFound)
490 return
491 }
492
493 user := rp.oauth.GetUser(r)
494
495 var breadcrumbs [][]string
496 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
497 if treePath != "" {
498 for idx, elem := range strings.Split(treePath, "/") {
499 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
500 }
501 }
502
503 sortFiles(result.Files)
504
505 rp.pages.RepoTree(w, pages.RepoTreeParams{
506 LoggedInUser: user,
507 BreadCrumbs: breadcrumbs,
508 TreePath: treePath,
509 RepoInfo: f.RepoInfo(user),
510 RepoTreeResponse: result,
511 })
512}
513
514func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
515 f, err := rp.repoResolver.Resolve(r)
516 if err != nil {
517 log.Println("failed to get repo and knot", err)
518 return
519 }
520
521 scheme := "http"
522 if !rp.config.Core.Dev {
523 scheme = "https"
524 }
525 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
526 xrpcc := &indigoxrpc.Client{
527 Host: host,
528 }
529
530 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
531 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
532 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
533 log.Println("failed to call XRPC repo.tags", xrpcerr)
534 rp.pages.Error503(w)
535 return
536 }
537
538 var result types.RepoTagsResponse
539 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
540 log.Println("failed to decode XRPC response", err)
541 rp.pages.Error503(w)
542 return
543 }
544
545 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
546 if err != nil {
547 log.Println("failed grab artifacts", err)
548 return
549 }
550
551 // convert artifacts to map for easy UI building
552 artifactMap := make(map[plumbing.Hash][]db.Artifact)
553 for _, a := range artifacts {
554 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
555 }
556
557 var danglingArtifacts []db.Artifact
558 for _, a := range artifacts {
559 found := false
560 for _, t := range result.Tags {
561 if t.Tag != nil {
562 if t.Tag.Hash == a.Tag {
563 found = true
564 }
565 }
566 }
567
568 if !found {
569 danglingArtifacts = append(danglingArtifacts, a)
570 }
571 }
572
573 user := rp.oauth.GetUser(r)
574 rp.pages.RepoTags(w, pages.RepoTagsParams{
575 LoggedInUser: user,
576 RepoInfo: f.RepoInfo(user),
577 RepoTagsResponse: result,
578 ArtifactMap: artifactMap,
579 DanglingArtifacts: danglingArtifacts,
580 })
581}
582
583func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
584 f, err := rp.repoResolver.Resolve(r)
585 if err != nil {
586 log.Println("failed to get repo and knot", err)
587 return
588 }
589
590 scheme := "http"
591 if !rp.config.Core.Dev {
592 scheme = "https"
593 }
594 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
595 xrpcc := &indigoxrpc.Client{
596 Host: host,
597 }
598
599 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
600 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
601 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
602 log.Println("failed to call XRPC repo.branches", xrpcerr)
603 rp.pages.Error503(w)
604 return
605 }
606
607 var result types.RepoBranchesResponse
608 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
609 log.Println("failed to decode XRPC response", err)
610 rp.pages.Error503(w)
611 return
612 }
613
614 sortBranches(result.Branches)
615
616 user := rp.oauth.GetUser(r)
617 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
618 LoggedInUser: user,
619 RepoInfo: f.RepoInfo(user),
620 RepoBranchesResponse: result,
621 })
622}
623
624func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
625 f, err := rp.repoResolver.Resolve(r)
626 if err != nil {
627 log.Println("failed to get repo and knot", err)
628 return
629 }
630
631 ref := chi.URLParam(r, "ref")
632 ref, _ = url.PathUnescape(ref)
633
634 filePath := chi.URLParam(r, "*")
635 filePath, _ = url.PathUnescape(filePath)
636
637 scheme := "http"
638 if !rp.config.Core.Dev {
639 scheme = "https"
640 }
641 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
642 xrpcc := &indigoxrpc.Client{
643 Host: host,
644 }
645
646 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
647 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
648 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
649 log.Println("failed to call XRPC repo.blob", xrpcerr)
650 rp.pages.Error503(w)
651 return
652 }
653
654 // Use XRPC response directly instead of converting to internal types
655
656 var breadcrumbs [][]string
657 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
658 if filePath != "" {
659 for idx, elem := range strings.Split(filePath, "/") {
660 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
661 }
662 }
663
664 showRendered := false
665 renderToggle := false
666
667 if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
668 renderToggle = true
669 showRendered = r.URL.Query().Get("code") != "true"
670 }
671
672 var unsupported bool
673 var isImage bool
674 var isVideo bool
675 var contentSrc string
676
677 if resp.IsBinary != nil && *resp.IsBinary {
678 ext := strings.ToLower(filepath.Ext(resp.Path))
679 switch ext {
680 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
681 isImage = true
682 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
683 isVideo = true
684 default:
685 unsupported = true
686 }
687
688 // fetch the raw binary content using sh.tangled.repo.blob xrpc
689 repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
690
691 baseURL := &url.URL{
692 Scheme: scheme,
693 Host: f.Knot,
694 Path: "/xrpc/sh.tangled.repo.blob",
695 }
696 query := baseURL.Query()
697 query.Set("repo", repoName)
698 query.Set("ref", ref)
699 query.Set("path", filePath)
700 query.Set("raw", "true")
701 baseURL.RawQuery = query.Encode()
702 blobURL := baseURL.String()
703
704 contentSrc = blobURL
705 if !rp.config.Core.Dev {
706 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
707 }
708 }
709
710 lines := 0
711 if resp.IsBinary == nil || !*resp.IsBinary {
712 lines = strings.Count(resp.Content, "\n") + 1
713 }
714
715 var sizeHint uint64
716 if resp.Size != nil {
717 sizeHint = uint64(*resp.Size)
718 } else {
719 sizeHint = uint64(len(resp.Content))
720 }
721
722 user := rp.oauth.GetUser(r)
723
724 // Determine if content is binary (dereference pointer)
725 isBinary := false
726 if resp.IsBinary != nil {
727 isBinary = *resp.IsBinary
728 }
729
730 rp.pages.RepoBlob(w, pages.RepoBlobParams{
731 LoggedInUser: user,
732 RepoInfo: f.RepoInfo(user),
733 BreadCrumbs: breadcrumbs,
734 ShowRendered: showRendered,
735 RenderToggle: renderToggle,
736 Unsupported: unsupported,
737 IsImage: isImage,
738 IsVideo: isVideo,
739 ContentSrc: contentSrc,
740 RepoBlob_Output: resp,
741 Contents: resp.Content,
742 Lines: lines,
743 SizeHint: sizeHint,
744 IsBinary: isBinary,
745 })
746}
747
748func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
749 f, err := rp.repoResolver.Resolve(r)
750 if err != nil {
751 log.Println("failed to get repo and knot", err)
752 w.WriteHeader(http.StatusBadRequest)
753 return
754 }
755
756 ref := chi.URLParam(r, "ref")
757 ref, _ = url.PathUnescape(ref)
758
759 filePath := chi.URLParam(r, "*")
760 filePath, _ = url.PathUnescape(filePath)
761
762 scheme := "http"
763 if !rp.config.Core.Dev {
764 scheme = "https"
765 }
766
767 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
768 baseURL := &url.URL{
769 Scheme: scheme,
770 Host: f.Knot,
771 Path: "/xrpc/sh.tangled.repo.blob",
772 }
773 query := baseURL.Query()
774 query.Set("repo", repo)
775 query.Set("ref", ref)
776 query.Set("path", filePath)
777 query.Set("raw", "true")
778 baseURL.RawQuery = query.Encode()
779 blobURL := baseURL.String()
780
781 req, err := http.NewRequest("GET", blobURL, nil)
782 if err != nil {
783 log.Println("failed to create request", err)
784 return
785 }
786
787 // forward the If-None-Match header
788 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
789 req.Header.Set("If-None-Match", clientETag)
790 }
791
792 client := &http.Client{}
793 resp, err := client.Do(req)
794 if err != nil {
795 log.Println("failed to reach knotserver", err)
796 rp.pages.Error503(w)
797 return
798 }
799 defer resp.Body.Close()
800
801 // forward 304 not modified
802 if resp.StatusCode == http.StatusNotModified {
803 w.WriteHeader(http.StatusNotModified)
804 return
805 }
806
807 if resp.StatusCode != http.StatusOK {
808 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
809 w.WriteHeader(resp.StatusCode)
810 _, _ = io.Copy(w, resp.Body)
811 return
812 }
813
814 contentType := resp.Header.Get("Content-Type")
815 body, err := io.ReadAll(resp.Body)
816 if err != nil {
817 log.Printf("error reading response body from knotserver: %v", err)
818 w.WriteHeader(http.StatusInternalServerError)
819 return
820 }
821
822 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
823 // serve all textual content as text/plain
824 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
825 w.Write(body)
826 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
827 // serve images and videos with their original content type
828 w.Header().Set("Content-Type", contentType)
829 w.Write(body)
830 } else {
831 w.WriteHeader(http.StatusUnsupportedMediaType)
832 w.Write([]byte("unsupported content type"))
833 return
834 }
835}
836
837// isTextualMimeType returns true if the MIME type represents textual content
838// that should be served as text/plain
839func isTextualMimeType(mimeType string) bool {
840 textualTypes := []string{
841 "application/json",
842 "application/xml",
843 "application/yaml",
844 "application/x-yaml",
845 "application/toml",
846 "application/javascript",
847 "application/ecmascript",
848 "message/",
849 }
850
851 return slices.Contains(textualTypes, mimeType)
852}
853
854// modify the spindle configured for this repo
855func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
856 user := rp.oauth.GetUser(r)
857 l := rp.logger.With("handler", "EditSpindle")
858 l = l.With("did", user.Did)
859 l = l.With("handle", user.Handle)
860
861 errorId := "operation-error"
862 fail := func(msg string, err error) {
863 l.Error(msg, "err", err)
864 rp.pages.Notice(w, errorId, msg)
865 }
866
867 f, err := rp.repoResolver.Resolve(r)
868 if err != nil {
869 fail("Failed to resolve repo. Try again later", err)
870 return
871 }
872
873 repoAt := f.RepoAt()
874 rkey := repoAt.RecordKey().String()
875 if rkey == "" {
876 fail("Failed to resolve repo. Try again later", err)
877 return
878 }
879
880 newSpindle := r.FormValue("spindle")
881 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
882 client, err := rp.oauth.AuthorizedClient(r)
883 if err != nil {
884 fail("Failed to authorize. Try again later.", err)
885 return
886 }
887
888 if !removingSpindle {
889 // ensure that this is a valid spindle for this user
890 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
891 if err != nil {
892 fail("Failed to find spindles. Try again later.", err)
893 return
894 }
895
896 if !slices.Contains(validSpindles, newSpindle) {
897 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
898 return
899 }
900 }
901
902 spindlePtr := &newSpindle
903 if removingSpindle {
904 spindlePtr = nil
905 }
906
907 // optimistic update
908 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
909 if err != nil {
910 fail("Failed to update spindle. Try again later.", err)
911 return
912 }
913
914 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
915 if err != nil {
916 fail("Failed to update spindle, no record found on PDS.", err)
917 return
918 }
919 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
920 Collection: tangled.RepoNSID,
921 Repo: user.Did,
922 Rkey: rkey,
923 SwapRecord: ex.Cid,
924 Record: &lexutil.LexiconTypeDecoder{
925 Val: &tangled.Repo{
926 Knot: f.Knot,
927 Name: f.Name,
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1368 log.Println("failed to call XRPC repo.branches", xrpcerr)
1369 rp.pages.Error503(w)
1370 return
1371 }
1372
1373 var result types.RepoBranchesResponse
1374 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1375 log.Println("failed to decode XRPC response", err)
1376 rp.pages.Error503(w)
1377 return
1378 }
1379
1380 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1381 LoggedInUser: user,
1382 RepoInfo: f.RepoInfo(user),
1383 Branches: result.Branches,
1384 Tabs: settingsTabs,
1385 Tab: "general",
1386 })
1387}
1388
1389func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1390 f, err := rp.repoResolver.Resolve(r)
1391 user := rp.oauth.GetUser(r)
1392
1393 repoCollaborators, err := f.Collaborators(r.Context())
1394 if err != nil {
1395 log.Println("failed to get collaborators", err)
1396 }
1397
1398 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1399 LoggedInUser: user,
1400 RepoInfo: f.RepoInfo(user),
1401 Tabs: settingsTabs,
1402 Tab: "access",
1403 Collaborators: repoCollaborators,
1404 })
1405}
1406
1407func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1408 f, err := rp.repoResolver.Resolve(r)
1409 user := rp.oauth.GetUser(r)
1410
1411 // all spindles that the repo owner is a member of
1412 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1413 if err != nil {
1414 log.Println("failed to fetch spindles", err)
1415 return
1416 }
1417
1418 var secrets []*tangled.RepoListSecrets_Secret
1419 if f.Spindle != "" {
1420 if spindleClient, err := rp.oauth.ServiceClient(
1421 r,
1422 oauth.WithService(f.Spindle),
1423 oauth.WithLxm(tangled.RepoListSecretsNSID),
1424 oauth.WithExp(60),
1425 oauth.WithDev(rp.config.Core.Dev),
1426 ); err != nil {
1427 log.Println("failed to create spindle client", err)
1428 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1429 log.Println("failed to fetch secrets", err)
1430 } else {
1431 secrets = resp.Secrets
1432 }
1433 }
1434
1435 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1436 return strings.Compare(a.Key, b.Key)
1437 })
1438
1439 var dids []string
1440 for _, s := range secrets {
1441 dids = append(dids, s.CreatedBy)
1442 }
1443 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1444
1445 // convert to a more manageable form
1446 var niceSecret []map[string]any
1447 for id, s := range secrets {
1448 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1449 niceSecret = append(niceSecret, map[string]any{
1450 "Id": id,
1451 "Key": s.Key,
1452 "CreatedAt": when,
1453 "CreatedBy": resolvedIdents[id].Handle.String(),
1454 })
1455 }
1456
1457 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1458 LoggedInUser: user,
1459 RepoInfo: f.RepoInfo(user),
1460 Tabs: settingsTabs,
1461 Tab: "pipelines",
1462 Spindles: spindles,
1463 CurrentSpindle: f.Spindle,
1464 Secrets: niceSecret,
1465 })
1466}
1467
1468func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1469 ref := chi.URLParam(r, "ref")
1470 ref, _ = url.PathUnescape(ref)
1471
1472 user := rp.oauth.GetUser(r)
1473 f, err := rp.repoResolver.Resolve(r)
1474 if err != nil {
1475 log.Printf("failed to resolve source repo: %v", err)
1476 return
1477 }
1478
1479 switch r.Method {
1480 case http.MethodPost:
1481 client, err := rp.oauth.ServiceClient(
1482 r,
1483 oauth.WithService(f.Knot),
1484 oauth.WithLxm(tangled.RepoForkSyncNSID),
1485 oauth.WithDev(rp.config.Core.Dev),
1486 )
1487 if err != nil {
1488 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1489 return
1490 }
1491
1492 repoInfo := f.RepoInfo(user)
1493 if repoInfo.Source == nil {
1494 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1495 return
1496 }
1497
1498 err = tangled.RepoForkSync(
1499 r.Context(),
1500 client,
1501 &tangled.RepoForkSync_Input{
1502 Did: user.Did,
1503 Name: f.Name,
1504 Source: repoInfo.Source.RepoAt().String(),
1505 Branch: ref,
1506 },
1507 )
1508 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1509 rp.pages.Notice(w, "repo", err.Error())
1510 return
1511 }
1512
1513 rp.pages.HxRefresh(w)
1514 return
1515 }
1516}
1517
1518func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1519 user := rp.oauth.GetUser(r)
1520 f, err := rp.repoResolver.Resolve(r)
1521 if err != nil {
1522 log.Printf("failed to resolve source repo: %v", err)
1523 return
1524 }
1525
1526 switch r.Method {
1527 case http.MethodGet:
1528 user := rp.oauth.GetUser(r)
1529 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1530 if err != nil {
1531 rp.pages.Notice(w, "repo", "Invalid user account.")
1532 return
1533 }
1534
1535 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1536 LoggedInUser: user,
1537 Knots: knots,
1538 RepoInfo: f.RepoInfo(user),
1539 })
1540
1541 case http.MethodPost:
1542 l := rp.logger.With("handler", "ForkRepo")
1543
1544 targetKnot := r.FormValue("knot")
1545 if targetKnot == "" {
1546 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1547 return
1548 }
1549 l = l.With("targetKnot", targetKnot)
1550
1551 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1552 if err != nil || !ok {
1553 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1554 return
1555 }
1556
1557 // choose a name for a fork
1558 forkName := f.Name
1559 // this check is *only* to see if the forked repo name already exists
1560 // in the user's account.
1561 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1562 if err != nil {
1563 if errors.Is(err, sql.ErrNoRows) {
1564 // no existing repo with this name found, we can use the name as is
1565 } else {
1566 log.Println("error fetching existing repo from db", err)
1567 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1568 return
1569 }
1570 } else if existingRepo != nil {
1571 // repo with this name already exists, append random string
1572 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1573 }
1574 l = l.With("forkName", forkName)
1575
1576 uri := "https"
1577 if rp.config.Core.Dev {
1578 uri = "http"
1579 }
1580
1581 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1582 l = l.With("cloneUrl", forkSourceUrl)
1583
1584 sourceAt := f.RepoAt().String()
1585
1586 // create an atproto record for this fork
1587 rkey := tid.TID()
1588 repo := &db.Repo{
1589 Did: user.Did,
1590 Name: forkName,
1591 Knot: targetKnot,
1592 Rkey: rkey,
1593 Source: sourceAt,
1594 }
1595
1596 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1597 if err != nil {
1598 l.Error("failed to create xrpcclient", "err", err)
1599 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1600 return
1601 }
1602
1603 createdAt := time.Now().Format(time.RFC3339)
1604 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1605 Collection: tangled.RepoNSID,
1606 Repo: user.Did,
1607 Rkey: rkey,
1608 Record: &lexutil.LexiconTypeDecoder{
1609 Val: &tangled.Repo{
1610 Knot: repo.Knot,
1611 Name: repo.Name,
1612 CreatedAt: createdAt,
1613 Source: &sourceAt,
1614 }},
1615 })
1616 if err != nil {
1617 l.Error("failed to write to PDS", "err", err)
1618 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1619 return
1620 }
1621
1622 aturi := atresp.Uri
1623 l = l.With("aturi", aturi)
1624 l.Info("wrote to PDS")
1625
1626 tx, err := rp.db.BeginTx(r.Context(), nil)
1627 if err != nil {
1628 l.Info("txn failed", "err", err)
1629 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1630 return
1631 }
1632
1633 // The rollback function reverts a few things on failure:
1634 // - the pending txn
1635 // - the ACLs
1636 // - the atproto record created
1637 rollback := func() {
1638 err1 := tx.Rollback()
1639 err2 := rp.enforcer.E.LoadPolicy()
1640 err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1641
1642 // ignore txn complete errors, this is okay
1643 if errors.Is(err1, sql.ErrTxDone) {
1644 err1 = nil
1645 }
1646
1647 if errs := errors.Join(err1, err2, err3); errs != nil {
1648 l.Error("failed to rollback changes", "errs", errs)
1649 return
1650 }
1651 }
1652 defer rollback()
1653
1654 client, err := rp.oauth.ServiceClient(
1655 r,
1656 oauth.WithService(targetKnot),
1657 oauth.WithLxm(tangled.RepoCreateNSID),
1658 oauth.WithDev(rp.config.Core.Dev),
1659 )
1660 if err != nil {
1661 l.Error("could not create service client", "err", err)
1662 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1663 return
1664 }
1665
1666 err = tangled.RepoCreate(
1667 r.Context(),
1668 client,
1669 &tangled.RepoCreate_Input{
1670 Rkey: rkey,
1671 Source: &forkSourceUrl,
1672 },
1673 )
1674 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1675 rp.pages.Notice(w, "repo", err.Error())
1676 return
1677 }
1678
1679 err = db.AddRepo(tx, repo)
1680 if err != nil {
1681 log.Println(err)
1682 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1683 return
1684 }
1685
1686 // acls
1687 p, _ := securejoin.SecureJoin(user.Did, forkName)
1688 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1689 if err != nil {
1690 log.Println(err)
1691 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1692 return
1693 }
1694
1695 err = tx.Commit()
1696 if err != nil {
1697 log.Println("failed to commit changes", err)
1698 http.Error(w, err.Error(), http.StatusInternalServerError)
1699 return
1700 }
1701
1702 err = rp.enforcer.E.SavePolicy()
1703 if err != nil {
1704 log.Println("failed to update ACLs", err)
1705 http.Error(w, err.Error(), http.StatusInternalServerError)
1706 return
1707 }
1708
1709 // reset the ATURI because the transaction completed successfully
1710 aturi = ""
1711
1712 rp.notifier.NewRepo(r.Context(), repo)
1713 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1714 }
1715}
1716
1717// this is used to rollback changes made to the PDS
1718//
1719// it is a no-op if the provided ATURI is empty
1720func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1721 if aturi == "" {
1722 return nil
1723 }
1724
1725 parsed := syntax.ATURI(aturi)
1726
1727 collection := parsed.Collection().String()
1728 repo := parsed.Authority().String()
1729 rkey := parsed.RecordKey().String()
1730
1731 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1732 Collection: collection,
1733 Repo: repo,
1734 Rkey: rkey,
1735 })
1736 return err
1737}
1738
1739func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1740 user := rp.oauth.GetUser(r)
1741 f, err := rp.repoResolver.Resolve(r)
1742 if err != nil {
1743 log.Println("failed to get repo and knot", err)
1744 return
1745 }
1746
1747 scheme := "http"
1748 if !rp.config.Core.Dev {
1749 scheme = "https"
1750 }
1751 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1752 xrpcc := &indigoxrpc.Client{
1753 Host: host,
1754 }
1755
1756 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1757 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1758 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1759 log.Println("failed to call XRPC repo.branches", xrpcerr)
1760 rp.pages.Error503(w)
1761 return
1762 }
1763
1764 var branchResult types.RepoBranchesResponse
1765 if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
1766 log.Println("failed to decode XRPC branches response", err)
1767 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1768 return
1769 }
1770 branches := branchResult.Branches
1771
1772 sortBranches(branches)
1773
1774 var defaultBranch string
1775 for _, b := range branches {
1776 if b.IsDefault {
1777 defaultBranch = b.Name
1778 }
1779 }
1780
1781 base := defaultBranch
1782 head := defaultBranch
1783
1784 params := r.URL.Query()
1785 queryBase := params.Get("base")
1786 queryHead := params.Get("head")
1787 if queryBase != "" {
1788 base = queryBase
1789 }
1790 if queryHead != "" {
1791 head = queryHead
1792 }
1793
1794 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1795 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1796 log.Println("failed to call XRPC repo.tags", xrpcerr)
1797 rp.pages.Error503(w)
1798 return
1799 }
1800
1801 var tags types.RepoTagsResponse
1802 if err := json.Unmarshal(tagBytes, &tags); err != nil {
1803 log.Println("failed to decode XRPC tags response", err)
1804 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1805 return
1806 }
1807
1808 repoinfo := f.RepoInfo(user)
1809
1810 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1811 LoggedInUser: user,
1812 RepoInfo: repoinfo,
1813 Branches: branches,
1814 Tags: tags.Tags,
1815 Base: base,
1816 Head: head,
1817 })
1818}
1819
1820func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1821 user := rp.oauth.GetUser(r)
1822 f, err := rp.repoResolver.Resolve(r)
1823 if err != nil {
1824 log.Println("failed to get repo and knot", err)
1825 return
1826 }
1827
1828 var diffOpts types.DiffOpts
1829 if d := r.URL.Query().Get("diff"); d == "split" {
1830 diffOpts.Split = true
1831 }
1832
1833 // if user is navigating to one of
1834 // /compare/{base}/{head}
1835 // /compare/{base}...{head}
1836 base := chi.URLParam(r, "base")
1837 head := chi.URLParam(r, "head")
1838 if base == "" && head == "" {
1839 rest := chi.URLParam(r, "*") // master...feature/xyz
1840 parts := strings.SplitN(rest, "...", 2)
1841 if len(parts) == 2 {
1842 base = parts[0]
1843 head = parts[1]
1844 }
1845 }
1846
1847 base, _ = url.PathUnescape(base)
1848 head, _ = url.PathUnescape(head)
1849
1850 if base == "" || head == "" {
1851 log.Printf("invalid comparison")
1852 rp.pages.Error404(w)
1853 return
1854 }
1855
1856 scheme := "http"
1857 if !rp.config.Core.Dev {
1858 scheme = "https"
1859 }
1860 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1861 xrpcc := &indigoxrpc.Client{
1862 Host: host,
1863 }
1864
1865 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1866
1867 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1868 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1869 log.Println("failed to call XRPC repo.branches", xrpcerr)
1870 rp.pages.Error503(w)
1871 return
1872 }
1873
1874 var branches types.RepoBranchesResponse
1875 if err := json.Unmarshal(branchBytes, &branches); err != nil {
1876 log.Println("failed to decode XRPC branches response", err)
1877 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1878 return
1879 }
1880
1881 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1882 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1883 log.Println("failed to call XRPC repo.tags", xrpcerr)
1884 rp.pages.Error503(w)
1885 return
1886 }
1887
1888 var tags types.RepoTagsResponse
1889 if err := json.Unmarshal(tagBytes, &tags); err != nil {
1890 log.Println("failed to decode XRPC tags response", err)
1891 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1892 return
1893 }
1894
1895 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
1896 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1897 log.Println("failed to call XRPC repo.compare", xrpcerr)
1898 rp.pages.Error503(w)
1899 return
1900 }
1901
1902 var formatPatch types.RepoFormatPatchResponse
1903 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
1904 log.Println("failed to decode XRPC compare response", err)
1905 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1906 return
1907 }
1908
1909 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1910
1911 repoinfo := f.RepoInfo(user)
1912
1913 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1914 LoggedInUser: user,
1915 RepoInfo: repoinfo,
1916 Branches: branches.Branches,
1917 Tags: tags.Tags,
1918 Base: base,
1919 Head: head,
1920 Diff: &diff,
1921 DiffOpts: diffOpts,
1922 })
1923
1924}