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