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