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