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