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