forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "net/url"
13 "path/filepath"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "tangled.org/core/api/tangled"
20 "tangled.org/core/appview/commitverify"
21 "tangled.org/core/appview/config"
22 "tangled.org/core/appview/db"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/notify"
25 "tangled.org/core/appview/oauth"
26 "tangled.org/core/appview/pages"
27 "tangled.org/core/appview/pages/markup"
28 "tangled.org/core/appview/reporesolver"
29 "tangled.org/core/appview/validator"
30 xrpcclient "tangled.org/core/appview/xrpcclient"
31 "tangled.org/core/eventconsumer"
32 "tangled.org/core/idresolver"
33 "tangled.org/core/patchutil"
34 "tangled.org/core/rbac"
35 "tangled.org/core/tid"
36 "tangled.org/core/types"
37 "tangled.org/core/xrpc/serviceauth"
38
39 comatproto "github.com/bluesky-social/indigo/api/atproto"
40 atpclient "github.com/bluesky-social/indigo/atproto/client"
41 "github.com/bluesky-social/indigo/atproto/syntax"
42 lexutil "github.com/bluesky-social/indigo/lex/util"
43 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44 securejoin "github.com/cyphar/filepath-securejoin"
45 "github.com/go-chi/chi/v5"
46 "github.com/go-git/go-git/v5/plumbing"
47)
48
49type Repo struct {
50 repoResolver *reporesolver.RepoResolver
51 idResolver *idresolver.Resolver
52 config *config.Config
53 oauth *oauth.OAuth
54 pages *pages.Pages
55 spindlestream *eventconsumer.Consumer
56 db *db.DB
57 enforcer *rbac.Enforcer
58 notifier notify.Notifier
59 logger *slog.Logger
60 serviceAuth *serviceauth.ServiceAuth
61 validator *validator.Validator
62}
63
64func New(
65 oauth *oauth.OAuth,
66 repoResolver *reporesolver.RepoResolver,
67 pages *pages.Pages,
68 spindlestream *eventconsumer.Consumer,
69 idResolver *idresolver.Resolver,
70 db *db.DB,
71 config *config.Config,
72 notifier notify.Notifier,
73 enforcer *rbac.Enforcer,
74 logger *slog.Logger,
75 validator *validator.Validator,
76) *Repo {
77 return &Repo{oauth: oauth,
78 repoResolver: repoResolver,
79 pages: pages,
80 idResolver: idResolver,
81 config: config,
82 spindlestream: spindlestream,
83 db: db,
84 notifier: notifier,
85 enforcer: enforcer,
86 logger: logger,
87 validator: validator,
88 }
89}
90
91func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
92 l := rp.logger.With("handler", "DownloadArchive")
93
94 ref := chi.URLParam(r, "ref")
95 ref, _ = url.PathUnescape(ref)
96
97 f, err := rp.repoResolver.Resolve(r)
98 if err != nil {
99 l.Error("failed to get repo and knot", "err", err)
100 return
101 }
102
103 scheme := "http"
104 if !rp.config.Core.Dev {
105 scheme = "https"
106 }
107 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
108 xrpcc := &indigoxrpc.Client{
109 Host: host,
110 }
111
112 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
113 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
114 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
115 l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
116 rp.pages.Error503(w)
117 return
118 }
119
120 // Set headers for file download, just pass along whatever the knot specifies
121 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
122 filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
123 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
124 w.Header().Set("Content-Type", "application/gzip")
125 w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
126
127 // Write the archive data directly
128 w.Write(archiveBytes)
129}
130
131func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
132 l := rp.logger.With("handler", "RepoLog")
133
134 f, err := rp.repoResolver.Resolve(r)
135 if err != nil {
136 l.Error("failed to fully resolve repo", "err", err)
137 return
138 }
139
140 page := 1
141 if r.URL.Query().Get("page") != "" {
142 page, err = strconv.Atoi(r.URL.Query().Get("page"))
143 if err != nil {
144 page = 1
145 }
146 }
147
148 ref := chi.URLParam(r, "ref")
149 ref, _ = url.PathUnescape(ref)
150
151 scheme := "http"
152 if !rp.config.Core.Dev {
153 scheme = "https"
154 }
155 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
156 xrpcc := &indigoxrpc.Client{
157 Host: host,
158 }
159
160 limit := int64(60)
161 cursor := ""
162 if page > 1 {
163 // Convert page number to cursor (offset)
164 offset := (page - 1) * int(limit)
165 cursor = strconv.Itoa(offset)
166 }
167
168 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
169 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
170 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
171 l.Error("failed to call XRPC repo.log", "err", xrpcerr)
172 rp.pages.Error503(w)
173 return
174 }
175
176 var xrpcResp types.RepoLogResponse
177 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
178 l.Error("failed to decode XRPC response", "err", err)
179 rp.pages.Error503(w)
180 return
181 }
182
183 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
185 l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
186 rp.pages.Error503(w)
187 return
188 }
189
190 tagMap := make(map[string][]string)
191 if tagBytes != nil {
192 var tagResp types.RepoTagsResponse
193 if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
194 for _, tag := range tagResp.Tags {
195 hash := tag.Hash
196 if tag.Tag != nil {
197 hash = tag.Tag.Target.String()
198 }
199 tagMap[hash] = append(tagMap[hash], tag.Name)
200 }
201 }
202 }
203
204 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
205 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
206 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
207 rp.pages.Error503(w)
208 return
209 }
210
211 if branchBytes != nil {
212 var branchResp types.RepoBranchesResponse
213 if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
214 for _, branch := range branchResp.Branches {
215 tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
216 }
217 }
218 }
219
220 user := rp.oauth.GetUser(r)
221
222 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
223 if err != nil {
224 l.Error("failed to fetch email to did mapping", "err", err)
225 }
226
227 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
228 if err != nil {
229 l.Error("failed to GetVerifiedObjectCommits", "err", err)
230 }
231
232 repoInfo := f.RepoInfo(user)
233
234 var shas []string
235 for _, c := range xrpcResp.Commits {
236 shas = append(shas, c.Hash.String())
237 }
238 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
239 if err != nil {
240 l.Error("failed to getPipelineStatuses", "err", err)
241 // non-fatal
242 }
243
244 rp.pages.RepoLog(w, pages.RepoLogParams{
245 LoggedInUser: user,
246 TagMap: tagMap,
247 RepoInfo: repoInfo,
248 RepoLogResponse: xrpcResp,
249 EmailToDid: emailToDidMap,
250 VerifiedCommits: vc,
251 Pipelines: pipelines,
252 })
253}
254
255func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
256 l := rp.logger.With("handler", "RepoCommit")
257
258 f, err := rp.repoResolver.Resolve(r)
259 if err != nil {
260 l.Error("failed to fully resolve repo", "err", err)
261 return
262 }
263 ref := chi.URLParam(r, "ref")
264 ref, _ = url.PathUnescape(ref)
265
266 var diffOpts types.DiffOpts
267 if d := r.URL.Query().Get("diff"); d == "split" {
268 diffOpts.Split = true
269 }
270
271 if !plumbing.IsHash(ref) {
272 rp.pages.Error404(w)
273 return
274 }
275
276 scheme := "http"
277 if !rp.config.Core.Dev {
278 scheme = "https"
279 }
280 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
281 xrpcc := &indigoxrpc.Client{
282 Host: host,
283 }
284
285 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
286 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
287 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
288 l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
289 rp.pages.Error503(w)
290 return
291 }
292
293 var result types.RepoCommitResponse
294 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
295 l.Error("failed to decode XRPC response", "err", err)
296 rp.pages.Error503(w)
297 return
298 }
299
300 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
301 if err != nil {
302 l.Error("failed to get email to did mapping", "err", err)
303 }
304
305 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
306 if err != nil {
307 l.Error("failed to GetVerifiedCommits", "err", err)
308 }
309
310 user := rp.oauth.GetUser(r)
311 repoInfo := f.RepoInfo(user)
312 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
313 if err != nil {
314 l.Error("failed to getPipelineStatuses", "err", err)
315 // non-fatal
316 }
317 var pipeline *models.Pipeline
318 if p, ok := pipelines[result.Diff.Commit.This]; ok {
319 pipeline = &p
320 }
321
322 rp.pages.RepoCommit(w, pages.RepoCommitParams{
323 LoggedInUser: user,
324 RepoInfo: f.RepoInfo(user),
325 RepoCommitResponse: result,
326 EmailToDid: emailToDidMap,
327 VerifiedCommit: vc,
328 Pipeline: pipeline,
329 DiffOpts: diffOpts,
330 })
331}
332
333func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
334 l := rp.logger.With("handler", "RepoTree")
335
336 f, err := rp.repoResolver.Resolve(r)
337 if err != nil {
338 l.Error("failed to fully resolve repo", "err", err)
339 return
340 }
341
342 ref := chi.URLParam(r, "ref")
343 ref, _ = url.PathUnescape(ref)
344
345 // if the tree path has a trailing slash, let's strip it
346 // so we don't 404
347 treePath := chi.URLParam(r, "*")
348 treePath, _ = url.PathUnescape(treePath)
349 treePath = strings.TrimSuffix(treePath, "/")
350
351 scheme := "http"
352 if !rp.config.Core.Dev {
353 scheme = "https"
354 }
355 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
356 xrpcc := &indigoxrpc.Client{
357 Host: host,
358 }
359
360 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
361 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
362 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
363 l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
364 rp.pages.Error503(w)
365 return
366 }
367
368 // Convert XRPC response to internal types.RepoTreeResponse
369 files := make([]types.NiceTree, len(xrpcResp.Files))
370 for i, xrpcFile := range xrpcResp.Files {
371 file := types.NiceTree{
372 Name: xrpcFile.Name,
373 Mode: xrpcFile.Mode,
374 Size: int64(xrpcFile.Size),
375 IsFile: xrpcFile.Is_file,
376 IsSubtree: xrpcFile.Is_subtree,
377 }
378
379 // Convert last commit info if present
380 if xrpcFile.Last_commit != nil {
381 commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
382 file.LastCommit = &types.LastCommitInfo{
383 Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
384 Message: xrpcFile.Last_commit.Message,
385 When: commitWhen,
386 }
387 }
388
389 files[i] = file
390 }
391
392 result := types.RepoTreeResponse{
393 Ref: xrpcResp.Ref,
394 Files: files,
395 }
396
397 if xrpcResp.Parent != nil {
398 result.Parent = *xrpcResp.Parent
399 }
400 if xrpcResp.Dotdot != nil {
401 result.DotDot = *xrpcResp.Dotdot
402 }
403 if xrpcResp.Readme != nil {
404 result.ReadmeFileName = xrpcResp.Readme.Filename
405 result.Readme = xrpcResp.Readme.Contents
406 }
407
408 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
409 // so we can safely redirect to the "parent" (which is the same file).
410 if len(result.Files) == 0 && result.Parent == treePath {
411 redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
412 http.Redirect(w, r, redirectTo, http.StatusFound)
413 return
414 }
415
416 user := rp.oauth.GetUser(r)
417
418 var breadcrumbs [][]string
419 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
420 if treePath != "" {
421 for idx, elem := range strings.Split(treePath, "/") {
422 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
423 }
424 }
425
426 sortFiles(result.Files)
427
428 rp.pages.RepoTree(w, pages.RepoTreeParams{
429 LoggedInUser: user,
430 BreadCrumbs: breadcrumbs,
431 TreePath: treePath,
432 RepoInfo: f.RepoInfo(user),
433 RepoTreeResponse: result,
434 })
435}
436
437func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
438 l := rp.logger.With("handler", "RepoTags")
439
440 f, err := rp.repoResolver.Resolve(r)
441 if err != nil {
442 l.Error("failed to get repo and knot", "err", err)
443 return
444 }
445
446 scheme := "http"
447 if !rp.config.Core.Dev {
448 scheme = "https"
449 }
450 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
451 xrpcc := &indigoxrpc.Client{
452 Host: host,
453 }
454
455 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
456 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
457 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
458 l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
459 rp.pages.Error503(w)
460 return
461 }
462
463 var result types.RepoTagsResponse
464 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
465 l.Error("failed to decode XRPC response", "err", err)
466 rp.pages.Error503(w)
467 return
468 }
469
470 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
471 if err != nil {
472 l.Error("failed grab artifacts", "err", err)
473 return
474 }
475
476 // convert artifacts to map for easy UI building
477 artifactMap := make(map[plumbing.Hash][]models.Artifact)
478 for _, a := range artifacts {
479 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
480 }
481
482 var danglingArtifacts []models.Artifact
483 for _, a := range artifacts {
484 found := false
485 for _, t := range result.Tags {
486 if t.Tag != nil {
487 if t.Tag.Hash == a.Tag {
488 found = true
489 }
490 }
491 }
492
493 if !found {
494 danglingArtifacts = append(danglingArtifacts, a)
495 }
496 }
497
498 user := rp.oauth.GetUser(r)
499 rp.pages.RepoTags(w, pages.RepoTagsParams{
500 LoggedInUser: user,
501 RepoInfo: f.RepoInfo(user),
502 RepoTagsResponse: result,
503 ArtifactMap: artifactMap,
504 DanglingArtifacts: danglingArtifacts,
505 })
506}
507
508func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
509 l := rp.logger.With("handler", "RepoBranches")
510
511 f, err := rp.repoResolver.Resolve(r)
512 if err != nil {
513 l.Error("failed to get repo and knot", "err", err)
514 return
515 }
516
517 scheme := "http"
518 if !rp.config.Core.Dev {
519 scheme = "https"
520 }
521 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
522 xrpcc := &indigoxrpc.Client{
523 Host: host,
524 }
525
526 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
527 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
528 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
529 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
530 rp.pages.Error503(w)
531 return
532 }
533
534 var result types.RepoBranchesResponse
535 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
536 l.Error("failed to decode XRPC response", "err", err)
537 rp.pages.Error503(w)
538 return
539 }
540
541 sortBranches(result.Branches)
542
543 user := rp.oauth.GetUser(r)
544 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
545 LoggedInUser: user,
546 RepoInfo: f.RepoInfo(user),
547 RepoBranchesResponse: result,
548 })
549}
550
551func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
552 l := rp.logger.With("handler", "DeleteBranch")
553
554 f, err := rp.repoResolver.Resolve(r)
555 if err != nil {
556 l.Error("failed to get repo and knot", "err", err)
557 return
558 }
559
560 noticeId := "delete-branch-error"
561 fail := func(msg string, err error) {
562 l.Error(msg, "err", err)
563 rp.pages.Notice(w, noticeId, msg)
564 }
565
566 branch := r.FormValue("branch")
567 if branch == "" {
568 fail("No branch provided.", nil)
569 return
570 }
571
572 client, err := rp.oauth.ServiceClient(
573 r,
574 oauth.WithService(f.Knot),
575 oauth.WithLxm(tangled.RepoDeleteBranchNSID),
576 oauth.WithDev(rp.config.Core.Dev),
577 )
578 if err != nil {
579 fail("Failed to connect to knotserver", nil)
580 return
581 }
582
583 err = tangled.RepoDeleteBranch(
584 r.Context(),
585 client,
586 &tangled.RepoDeleteBranch_Input{
587 Branch: branch,
588 Repo: f.RepoAt().String(),
589 },
590 )
591 if err := xrpcclient.HandleXrpcErr(err); err != nil {
592 fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
593 return
594 }
595 l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
596
597 rp.pages.HxRefresh(w)
598}
599
600func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
601 l := rp.logger.With("handler", "RepoBlob")
602
603 f, err := rp.repoResolver.Resolve(r)
604 if err != nil {
605 l.Error("failed to get repo and knot", "err", err)
606 return
607 }
608
609 ref := chi.URLParam(r, "ref")
610 ref, _ = url.PathUnescape(ref)
611
612 filePath := chi.URLParam(r, "*")
613 filePath, _ = url.PathUnescape(filePath)
614
615 scheme := "http"
616 if !rp.config.Core.Dev {
617 scheme = "https"
618 }
619 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
620 xrpcc := &indigoxrpc.Client{
621 Host: host,
622 }
623
624 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
625 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
626 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
627 l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
628 rp.pages.Error503(w)
629 return
630 }
631
632 // Use XRPC response directly instead of converting to internal types
633
634 var breadcrumbs [][]string
635 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
636 if filePath != "" {
637 for idx, elem := range strings.Split(filePath, "/") {
638 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
639 }
640 }
641
642 showRendered := false
643 renderToggle := false
644
645 if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
646 renderToggle = true
647 showRendered = r.URL.Query().Get("code") != "true"
648 }
649
650 var unsupported bool
651 var isImage bool
652 var isVideo bool
653 var contentSrc string
654
655 if resp.IsBinary != nil && *resp.IsBinary {
656 ext := strings.ToLower(filepath.Ext(resp.Path))
657 switch ext {
658 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
659 isImage = true
660 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
661 isVideo = true
662 default:
663 unsupported = true
664 }
665
666 // fetch the raw binary content using sh.tangled.repo.blob xrpc
667 repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
668
669 baseURL := &url.URL{
670 Scheme: scheme,
671 Host: f.Knot,
672 Path: "/xrpc/sh.tangled.repo.blob",
673 }
674 query := baseURL.Query()
675 query.Set("repo", repoName)
676 query.Set("ref", ref)
677 query.Set("path", filePath)
678 query.Set("raw", "true")
679 baseURL.RawQuery = query.Encode()
680 blobURL := baseURL.String()
681
682 contentSrc = blobURL
683 if !rp.config.Core.Dev {
684 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
685 }
686 }
687
688 lines := 0
689 if resp.IsBinary == nil || !*resp.IsBinary {
690 lines = strings.Count(resp.Content, "\n") + 1
691 }
692
693 var sizeHint uint64
694 if resp.Size != nil {
695 sizeHint = uint64(*resp.Size)
696 } else {
697 sizeHint = uint64(len(resp.Content))
698 }
699
700 user := rp.oauth.GetUser(r)
701
702 // Determine if content is binary (dereference pointer)
703 isBinary := false
704 if resp.IsBinary != nil {
705 isBinary = *resp.IsBinary
706 }
707
708 rp.pages.RepoBlob(w, pages.RepoBlobParams{
709 LoggedInUser: user,
710 RepoInfo: f.RepoInfo(user),
711 BreadCrumbs: breadcrumbs,
712 ShowRendered: showRendered,
713 RenderToggle: renderToggle,
714 Unsupported: unsupported,
715 IsImage: isImage,
716 IsVideo: isVideo,
717 ContentSrc: contentSrc,
718 RepoBlob_Output: resp,
719 Contents: resp.Content,
720 Lines: lines,
721 SizeHint: sizeHint,
722 IsBinary: isBinary,
723 })
724}
725
726func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
727 l := rp.logger.With("handler", "RepoBlobRaw")
728
729 f, err := rp.repoResolver.Resolve(r)
730 if err != nil {
731 l.Error("failed to get repo and knot", "err", err)
732 w.WriteHeader(http.StatusBadRequest)
733 return
734 }
735
736 ref := chi.URLParam(r, "ref")
737 ref, _ = url.PathUnescape(ref)
738
739 filePath := chi.URLParam(r, "*")
740 filePath, _ = url.PathUnescape(filePath)
741
742 scheme := "http"
743 if !rp.config.Core.Dev {
744 scheme = "https"
745 }
746
747 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
748 baseURL := &url.URL{
749 Scheme: scheme,
750 Host: f.Knot,
751 Path: "/xrpc/sh.tangled.repo.blob",
752 }
753 query := baseURL.Query()
754 query.Set("repo", repo)
755 query.Set("ref", ref)
756 query.Set("path", filePath)
757 query.Set("raw", "true")
758 baseURL.RawQuery = query.Encode()
759 blobURL := baseURL.String()
760
761 req, err := http.NewRequest("GET", blobURL, nil)
762 if err != nil {
763 l.Error("failed to create request", "err", err)
764 return
765 }
766
767 // forward the If-None-Match header
768 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
769 req.Header.Set("If-None-Match", clientETag)
770 }
771
772 client := &http.Client{}
773 resp, err := client.Do(req)
774 if err != nil {
775 l.Error("failed to reach knotserver", "err", err)
776 rp.pages.Error503(w)
777 return
778 }
779 defer resp.Body.Close()
780
781 // forward 304 not modified
782 if resp.StatusCode == http.StatusNotModified {
783 w.WriteHeader(http.StatusNotModified)
784 return
785 }
786
787 if resp.StatusCode != http.StatusOK {
788 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
789 w.WriteHeader(resp.StatusCode)
790 _, _ = io.Copy(w, resp.Body)
791 return
792 }
793
794 contentType := resp.Header.Get("Content-Type")
795 body, err := io.ReadAll(resp.Body)
796 if err != nil {
797 l.Error("error reading response body from knotserver", "err", err)
798 w.WriteHeader(http.StatusInternalServerError)
799 return
800 }
801
802 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
803 // serve all textual content as text/plain
804 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
805 w.Write(body)
806 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
807 // serve images and videos with their original content type
808 w.Header().Set("Content-Type", contentType)
809 w.Write(body)
810 } else {
811 w.WriteHeader(http.StatusUnsupportedMediaType)
812 w.Write([]byte("unsupported content type"))
813 return
814 }
815}
816
817// isTextualMimeType returns true if the MIME type represents textual content
818// that should be served as text/plain
819func isTextualMimeType(mimeType string) bool {
820 textualTypes := []string{
821 "application/json",
822 "application/xml",
823 "application/yaml",
824 "application/x-yaml",
825 "application/toml",
826 "application/javascript",
827 "application/ecmascript",
828 "message/",
829 }
830
831 return slices.Contains(textualTypes, mimeType)
832}
833
834// modify the spindle configured for this repo
835func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
836 user := rp.oauth.GetUser(r)
837 l := rp.logger.With("handler", "EditSpindle")
838 l = l.With("did", user.Did)
839
840 errorId := "operation-error"
841 fail := func(msg string, err error) {
842 l.Error(msg, "err", err)
843 rp.pages.Notice(w, errorId, msg)
844 }
845
846 f, err := rp.repoResolver.Resolve(r)
847 if err != nil {
848 fail("Failed to resolve repo. Try again later", err)
849 return
850 }
851
852 newSpindle := r.FormValue("spindle")
853 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
854 client, err := rp.oauth.AuthorizedClient(r)
855 if err != nil {
856 fail("Failed to authorize. Try again later.", err)
857 return
858 }
859
860 if !removingSpindle {
861 // ensure that this is a valid spindle for this user
862 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
863 if err != nil {
864 fail("Failed to find spindles. Try again later.", err)
865 return
866 }
867
868 if !slices.Contains(validSpindles, newSpindle) {
869 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
870 return
871 }
872 }
873
874 newRepo := f.Repo
875 newRepo.Spindle = newSpindle
876 record := newRepo.AsRecord()
877
878 spindlePtr := &newSpindle
879 if removingSpindle {
880 spindlePtr = nil
881 newRepo.Spindle = ""
882 }
883
884 // optimistic update
885 err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr)
886 if err != nil {
887 fail("Failed to update spindle. Try again later.", err)
888 return
889 }
890
891 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
892 if err != nil {
893 fail("Failed to update spindle, no record found on PDS.", err)
894 return
895 }
896 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
897 Collection: tangled.RepoNSID,
898 Repo: newRepo.Did,
899 Rkey: newRepo.Rkey,
900 SwapRecord: ex.Cid,
901 Record: &lexutil.LexiconTypeDecoder{
902 Val: &record,
903 },
904 })
905
906 if err != nil {
907 fail("Failed to update spindle, unable to save to PDS.", err)
908 return
909 }
910
911 if !removingSpindle {
912 // add this spindle to spindle stream
913 rp.spindlestream.AddSource(
914 context.Background(),
915 eventconsumer.NewSpindleSource(newSpindle),
916 )
917 }
918
919 rp.pages.HxRefresh(w)
920}
921
922func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
923 user := rp.oauth.GetUser(r)
924 l := rp.logger.With("handler", "AddLabel")
925 l = l.With("did", user.Did)
926
927 f, err := rp.repoResolver.Resolve(r)
928 if err != nil {
929 l.Error("failed to get repo and knot", "err", err)
930 return
931 }
932
933 errorId := "add-label-error"
934 fail := func(msg string, err error) {
935 l.Error(msg, "err", err)
936 rp.pages.Notice(w, errorId, msg)
937 }
938
939 // get form values for label definition
940 name := r.FormValue("name")
941 concreteType := r.FormValue("valueType")
942 valueFormat := r.FormValue("valueFormat")
943 enumValues := r.FormValue("enumValues")
944 scope := r.Form["scope"]
945 color := r.FormValue("color")
946 multiple := r.FormValue("multiple") == "true"
947
948 var variants []string
949 for part := range strings.SplitSeq(enumValues, ",") {
950 if part = strings.TrimSpace(part); part != "" {
951 variants = append(variants, part)
952 }
953 }
954
955 if concreteType == "" {
956 concreteType = "null"
957 }
958
959 format := models.ValueTypeFormatAny
960 if valueFormat == "did" {
961 format = models.ValueTypeFormatDid
962 }
963
964 valueType := models.ValueType{
965 Type: models.ConcreteType(concreteType),
966 Format: format,
967 Enum: variants,
968 }
969
970 label := models.LabelDefinition{
971 Did: user.Did,
972 Rkey: tid.TID(),
973 Name: name,
974 ValueType: valueType,
975 Scope: scope,
976 Color: &color,
977 Multiple: multiple,
978 Created: time.Now(),
979 }
980 if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
981 fail(err.Error(), err)
982 return
983 }
984
985 // announce this relation into the firehose, store into owners' pds
986 client, err := rp.oauth.AuthorizedClient(r)
987 if err != nil {
988 fail(err.Error(), err)
989 return
990 }
991
992 // emit a labelRecord
993 labelRecord := label.AsRecord()
994 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
995 Collection: tangled.LabelDefinitionNSID,
996 Repo: label.Did,
997 Rkey: label.Rkey,
998 Record: &lexutil.LexiconTypeDecoder{
999 Val: &labelRecord,
1000 },
1001 })
1002 // invalid record
1003 if err != nil {
1004 fail("Failed to write record to PDS.", err)
1005 return
1006 }
1007
1008 aturi := resp.Uri
1009 l = l.With("at-uri", aturi)
1010 l.Info("wrote label record to PDS")
1011
1012 // update the repo to subscribe to this label
1013 newRepo := f.Repo
1014 newRepo.Labels = append(newRepo.Labels, aturi)
1015 repoRecord := newRepo.AsRecord()
1016
1017 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1018 if err != nil {
1019 fail("Failed to update labels, no record found on PDS.", err)
1020 return
1021 }
1022 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1023 Collection: tangled.RepoNSID,
1024 Repo: newRepo.Did,
1025 Rkey: newRepo.Rkey,
1026 SwapRecord: ex.Cid,
1027 Record: &lexutil.LexiconTypeDecoder{
1028 Val: &repoRecord,
1029 },
1030 })
1031 if err != nil {
1032 fail("Failed to update labels for repo.", err)
1033 return
1034 }
1035
1036 tx, err := rp.db.BeginTx(r.Context(), nil)
1037 if err != nil {
1038 fail("Failed to add label.", err)
1039 return
1040 }
1041
1042 rollback := func() {
1043 err1 := tx.Rollback()
1044 err2 := rollbackRecord(context.Background(), aturi, client)
1045
1046 // ignore txn complete errors, this is okay
1047 if errors.Is(err1, sql.ErrTxDone) {
1048 err1 = nil
1049 }
1050
1051 if errs := errors.Join(err1, err2); errs != nil {
1052 l.Error("failed to rollback changes", "errs", errs)
1053 return
1054 }
1055 }
1056 defer rollback()
1057
1058 _, err = db.AddLabelDefinition(tx, &label)
1059 if err != nil {
1060 fail("Failed to add label.", err)
1061 return
1062 }
1063
1064 err = db.SubscribeLabel(tx, &models.RepoLabel{
1065 RepoAt: f.RepoAt(),
1066 LabelAt: label.AtUri(),
1067 })
1068
1069 err = tx.Commit()
1070 if err != nil {
1071 fail("Failed to add label.", err)
1072 return
1073 }
1074
1075 // clear aturi when everything is successful
1076 aturi = ""
1077
1078 rp.pages.HxRefresh(w)
1079}
1080
1081func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1082 user := rp.oauth.GetUser(r)
1083 l := rp.logger.With("handler", "DeleteLabel")
1084 l = l.With("did", user.Did)
1085
1086 f, err := rp.repoResolver.Resolve(r)
1087 if err != nil {
1088 l.Error("failed to get repo and knot", "err", err)
1089 return
1090 }
1091
1092 errorId := "label-operation"
1093 fail := func(msg string, err error) {
1094 l.Error(msg, "err", err)
1095 rp.pages.Notice(w, errorId, msg)
1096 }
1097
1098 // get form values
1099 labelId := r.FormValue("label-id")
1100
1101 label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
1102 if err != nil {
1103 fail("Failed to find label definition.", err)
1104 return
1105 }
1106
1107 client, err := rp.oauth.AuthorizedClient(r)
1108 if err != nil {
1109 fail(err.Error(), err)
1110 return
1111 }
1112
1113 // delete label record from PDS
1114 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1115 Collection: tangled.LabelDefinitionNSID,
1116 Repo: label.Did,
1117 Rkey: label.Rkey,
1118 })
1119 if err != nil {
1120 fail("Failed to delete label record from PDS.", err)
1121 return
1122 }
1123
1124 // update repo record to remove the label reference
1125 newRepo := f.Repo
1126 var updated []string
1127 removedAt := label.AtUri().String()
1128 for _, l := range newRepo.Labels {
1129 if l != removedAt {
1130 updated = append(updated, l)
1131 }
1132 }
1133 newRepo.Labels = updated
1134 repoRecord := newRepo.AsRecord()
1135
1136 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1137 if err != nil {
1138 fail("Failed to update labels, no record found on PDS.", err)
1139 return
1140 }
1141 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1142 Collection: tangled.RepoNSID,
1143 Repo: newRepo.Did,
1144 Rkey: newRepo.Rkey,
1145 SwapRecord: ex.Cid,
1146 Record: &lexutil.LexiconTypeDecoder{
1147 Val: &repoRecord,
1148 },
1149 })
1150 if err != nil {
1151 fail("Failed to update repo record.", err)
1152 return
1153 }
1154
1155 // transaction for DB changes
1156 tx, err := rp.db.BeginTx(r.Context(), nil)
1157 if err != nil {
1158 fail("Failed to delete label.", err)
1159 return
1160 }
1161 defer tx.Rollback()
1162
1163 err = db.UnsubscribeLabel(
1164 tx,
1165 db.FilterEq("repo_at", f.RepoAt()),
1166 db.FilterEq("label_at", removedAt),
1167 )
1168 if err != nil {
1169 fail("Failed to unsubscribe label.", err)
1170 return
1171 }
1172
1173 err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
1174 if err != nil {
1175 fail("Failed to delete label definition.", err)
1176 return
1177 }
1178
1179 err = tx.Commit()
1180 if err != nil {
1181 fail("Failed to delete label.", err)
1182 return
1183 }
1184
1185 // everything succeeded
1186 rp.pages.HxRefresh(w)
1187}
1188
1189func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
1190 user := rp.oauth.GetUser(r)
1191 l := rp.logger.With("handler", "SubscribeLabel")
1192 l = l.With("did", user.Did)
1193
1194 f, err := rp.repoResolver.Resolve(r)
1195 if err != nil {
1196 l.Error("failed to get repo and knot", "err", err)
1197 return
1198 }
1199
1200 if err := r.ParseForm(); err != nil {
1201 l.Error("invalid form", "err", err)
1202 return
1203 }
1204
1205 errorId := "default-label-operation"
1206 fail := func(msg string, err error) {
1207 l.Error(msg, "err", err)
1208 rp.pages.Notice(w, errorId, msg)
1209 }
1210
1211 labelAts := r.Form["label"]
1212 _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1213 if err != nil {
1214 fail("Failed to subscribe to label.", err)
1215 return
1216 }
1217
1218 newRepo := f.Repo
1219 newRepo.Labels = append(newRepo.Labels, labelAts...)
1220
1221 // dedup
1222 slices.Sort(newRepo.Labels)
1223 newRepo.Labels = slices.Compact(newRepo.Labels)
1224
1225 repoRecord := newRepo.AsRecord()
1226
1227 client, err := rp.oauth.AuthorizedClient(r)
1228 if err != nil {
1229 fail(err.Error(), err)
1230 return
1231 }
1232
1233 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1234 if err != nil {
1235 fail("Failed to update labels, no record found on PDS.", err)
1236 return
1237 }
1238 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1239 Collection: tangled.RepoNSID,
1240 Repo: newRepo.Did,
1241 Rkey: newRepo.Rkey,
1242 SwapRecord: ex.Cid,
1243 Record: &lexutil.LexiconTypeDecoder{
1244 Val: &repoRecord,
1245 },
1246 })
1247
1248 tx, err := rp.db.Begin()
1249 if err != nil {
1250 fail("Failed to subscribe to label.", err)
1251 return
1252 }
1253 defer tx.Rollback()
1254
1255 for _, l := range labelAts {
1256 err = db.SubscribeLabel(tx, &models.RepoLabel{
1257 RepoAt: f.RepoAt(),
1258 LabelAt: syntax.ATURI(l),
1259 })
1260 if err != nil {
1261 fail("Failed to subscribe to label.", err)
1262 return
1263 }
1264 }
1265
1266 if err := tx.Commit(); err != nil {
1267 fail("Failed to subscribe to label.", err)
1268 return
1269 }
1270
1271 // everything succeeded
1272 rp.pages.HxRefresh(w)
1273}
1274
1275func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
1276 user := rp.oauth.GetUser(r)
1277 l := rp.logger.With("handler", "UnsubscribeLabel")
1278 l = l.With("did", user.Did)
1279
1280 f, err := rp.repoResolver.Resolve(r)
1281 if err != nil {
1282 l.Error("failed to get repo and knot", "err", err)
1283 return
1284 }
1285
1286 if err := r.ParseForm(); err != nil {
1287 l.Error("invalid form", "err", err)
1288 return
1289 }
1290
1291 errorId := "default-label-operation"
1292 fail := func(msg string, err error) {
1293 l.Error(msg, "err", err)
1294 rp.pages.Notice(w, errorId, msg)
1295 }
1296
1297 labelAts := r.Form["label"]
1298 _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1299 if err != nil {
1300 fail("Failed to unsubscribe to label.", err)
1301 return
1302 }
1303
1304 // update repo record to remove the label reference
1305 newRepo := f.Repo
1306 var updated []string
1307 for _, l := range newRepo.Labels {
1308 if !slices.Contains(labelAts, l) {
1309 updated = append(updated, l)
1310 }
1311 }
1312 newRepo.Labels = updated
1313 repoRecord := newRepo.AsRecord()
1314
1315 client, err := rp.oauth.AuthorizedClient(r)
1316 if err != nil {
1317 fail(err.Error(), err)
1318 return
1319 }
1320
1321 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1322 if err != nil {
1323 fail("Failed to update labels, no record found on PDS.", err)
1324 return
1325 }
1326 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1327 Collection: tangled.RepoNSID,
1328 Repo: newRepo.Did,
1329 Rkey: newRepo.Rkey,
1330 SwapRecord: ex.Cid,
1331 Record: &lexutil.LexiconTypeDecoder{
1332 Val: &repoRecord,
1333 },
1334 })
1335
1336 err = db.UnsubscribeLabel(
1337 rp.db,
1338 db.FilterEq("repo_at", f.RepoAt()),
1339 db.FilterIn("label_at", labelAts),
1340 )
1341 if err != nil {
1342 fail("Failed to unsubscribe label.", err)
1343 return
1344 }
1345
1346 // everything succeeded
1347 rp.pages.HxRefresh(w)
1348}
1349
1350func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1351 l := rp.logger.With("handler", "LabelPanel")
1352
1353 f, err := rp.repoResolver.Resolve(r)
1354 if err != nil {
1355 l.Error("failed to get repo and knot", "err", err)
1356 return
1357 }
1358
1359 subjectStr := r.FormValue("subject")
1360 subject, err := syntax.ParseATURI(subjectStr)
1361 if err != nil {
1362 l.Error("failed to get repo and knot", "err", err)
1363 return
1364 }
1365
1366 labelDefs, err := db.GetLabelDefinitions(
1367 rp.db,
1368 db.FilterIn("at_uri", f.Repo.Labels),
1369 db.FilterContains("scope", subject.Collection().String()),
1370 )
1371 if err != nil {
1372 l.Error("failed to fetch label defs", "err", err)
1373 return
1374 }
1375
1376 defs := make(map[string]*models.LabelDefinition)
1377 for _, l := range labelDefs {
1378 defs[l.AtUri().String()] = &l
1379 }
1380
1381 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1382 if err != nil {
1383 l.Error("failed to build label state", "err", err)
1384 return
1385 }
1386 state := states[subject]
1387
1388 user := rp.oauth.GetUser(r)
1389 rp.pages.LabelPanel(w, pages.LabelPanelParams{
1390 LoggedInUser: user,
1391 RepoInfo: f.RepoInfo(user),
1392 Defs: defs,
1393 Subject: subject.String(),
1394 State: state,
1395 })
1396}
1397
1398func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1399 l := rp.logger.With("handler", "EditLabelPanel")
1400
1401 f, err := rp.repoResolver.Resolve(r)
1402 if err != nil {
1403 l.Error("failed to get repo and knot", "err", err)
1404 return
1405 }
1406
1407 subjectStr := r.FormValue("subject")
1408 subject, err := syntax.ParseATURI(subjectStr)
1409 if err != nil {
1410 l.Error("failed to get repo and knot", "err", err)
1411 return
1412 }
1413
1414 labelDefs, err := db.GetLabelDefinitions(
1415 rp.db,
1416 db.FilterIn("at_uri", f.Repo.Labels),
1417 db.FilterContains("scope", subject.Collection().String()),
1418 )
1419 if err != nil {
1420 l.Error("failed to fetch labels", "err", err)
1421 return
1422 }
1423
1424 defs := make(map[string]*models.LabelDefinition)
1425 for _, l := range labelDefs {
1426 defs[l.AtUri().String()] = &l
1427 }
1428
1429 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1430 if err != nil {
1431 l.Error("failed to build label state", "err", err)
1432 return
1433 }
1434 state := states[subject]
1435
1436 user := rp.oauth.GetUser(r)
1437 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1438 LoggedInUser: user,
1439 RepoInfo: f.RepoInfo(user),
1440 Defs: defs,
1441 Subject: subject.String(),
1442 State: state,
1443 })
1444}
1445
1446func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
1447 user := rp.oauth.GetUser(r)
1448 l := rp.logger.With("handler", "AddCollaborator")
1449 l = l.With("did", user.Did)
1450
1451 f, err := rp.repoResolver.Resolve(r)
1452 if err != nil {
1453 l.Error("failed to get repo and knot", "err", err)
1454 return
1455 }
1456
1457 errorId := "add-collaborator-error"
1458 fail := func(msg string, err error) {
1459 l.Error(msg, "err", err)
1460 rp.pages.Notice(w, errorId, msg)
1461 }
1462
1463 collaborator := r.FormValue("collaborator")
1464 if collaborator == "" {
1465 fail("Invalid form.", nil)
1466 return
1467 }
1468
1469 // remove a single leading `@`, to make @handle work with ResolveIdent
1470 collaborator = strings.TrimPrefix(collaborator, "@")
1471
1472 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
1473 if err != nil {
1474 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
1475 return
1476 }
1477
1478 if collaboratorIdent.DID.String() == user.Did {
1479 fail("You seem to be adding yourself as a collaborator.", nil)
1480 return
1481 }
1482 l = l.With("collaborator", collaboratorIdent.Handle)
1483 l = l.With("knot", f.Knot)
1484
1485 // announce this relation into the firehose, store into owners' pds
1486 client, err := rp.oauth.AuthorizedClient(r)
1487 if err != nil {
1488 fail("Failed to write to PDS.", err)
1489 return
1490 }
1491
1492 // emit a record
1493 currentUser := rp.oauth.GetUser(r)
1494 rkey := tid.TID()
1495 createdAt := time.Now()
1496 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1497 Collection: tangled.RepoCollaboratorNSID,
1498 Repo: currentUser.Did,
1499 Rkey: rkey,
1500 Record: &lexutil.LexiconTypeDecoder{
1501 Val: &tangled.RepoCollaborator{
1502 Subject: collaboratorIdent.DID.String(),
1503 Repo: string(f.RepoAt()),
1504 CreatedAt: createdAt.Format(time.RFC3339),
1505 }},
1506 })
1507 // invalid record
1508 if err != nil {
1509 fail("Failed to write record to PDS.", err)
1510 return
1511 }
1512
1513 aturi := resp.Uri
1514 l = l.With("at-uri", aturi)
1515 l.Info("wrote record to PDS")
1516
1517 tx, err := rp.db.BeginTx(r.Context(), nil)
1518 if err != nil {
1519 fail("Failed to add collaborator.", err)
1520 return
1521 }
1522
1523 rollback := func() {
1524 err1 := tx.Rollback()
1525 err2 := rp.enforcer.E.LoadPolicy()
1526 err3 := rollbackRecord(context.Background(), aturi, client)
1527
1528 // ignore txn complete errors, this is okay
1529 if errors.Is(err1, sql.ErrTxDone) {
1530 err1 = nil
1531 }
1532
1533 if errs := errors.Join(err1, err2, err3); errs != nil {
1534 l.Error("failed to rollback changes", "errs", errs)
1535 return
1536 }
1537 }
1538 defer rollback()
1539
1540 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
1541 if err != nil {
1542 fail("Failed to add collaborator permissions.", err)
1543 return
1544 }
1545
1546 err = db.AddCollaborator(tx, models.Collaborator{
1547 Did: syntax.DID(currentUser.Did),
1548 Rkey: rkey,
1549 SubjectDid: collaboratorIdent.DID,
1550 RepoAt: f.RepoAt(),
1551 Created: createdAt,
1552 })
1553 if err != nil {
1554 fail("Failed to add collaborator.", err)
1555 return
1556 }
1557
1558 err = tx.Commit()
1559 if err != nil {
1560 fail("Failed to add collaborator.", err)
1561 return
1562 }
1563
1564 err = rp.enforcer.E.SavePolicy()
1565 if err != nil {
1566 fail("Failed to update collaborator permissions.", err)
1567 return
1568 }
1569
1570 // clear aturi to when everything is successful
1571 aturi = ""
1572
1573 rp.pages.HxRefresh(w)
1574}
1575
1576func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1577 user := rp.oauth.GetUser(r)
1578 l := rp.logger.With("handler", "DeleteRepo")
1579
1580 noticeId := "operation-error"
1581 f, err := rp.repoResolver.Resolve(r)
1582 if err != nil {
1583 l.Error("failed to get repo and knot", "err", err)
1584 return
1585 }
1586
1587 // remove record from pds
1588 atpClient, err := rp.oauth.AuthorizedClient(r)
1589 if err != nil {
1590 l.Error("failed to get authorized client", "err", err)
1591 return
1592 }
1593 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1594 Collection: tangled.RepoNSID,
1595 Repo: user.Did,
1596 Rkey: f.Rkey,
1597 })
1598 if err != nil {
1599 l.Error("failed to delete record", "err", err)
1600 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1601 return
1602 }
1603 l.Info("removed repo record", "aturi", f.RepoAt().String())
1604
1605 client, err := rp.oauth.ServiceClient(
1606 r,
1607 oauth.WithService(f.Knot),
1608 oauth.WithLxm(tangled.RepoDeleteNSID),
1609 oauth.WithDev(rp.config.Core.Dev),
1610 )
1611 if err != nil {
1612 l.Error("failed to connect to knot server", "err", err)
1613 return
1614 }
1615
1616 err = tangled.RepoDelete(
1617 r.Context(),
1618 client,
1619 &tangled.RepoDelete_Input{
1620 Did: f.OwnerDid(),
1621 Name: f.Name,
1622 Rkey: f.Rkey,
1623 },
1624 )
1625 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1626 rp.pages.Notice(w, noticeId, err.Error())
1627 return
1628 }
1629 l.Info("deleted repo from knot")
1630
1631 tx, err := rp.db.BeginTx(r.Context(), nil)
1632 if err != nil {
1633 l.Error("failed to start tx")
1634 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1635 return
1636 }
1637 defer func() {
1638 tx.Rollback()
1639 err = rp.enforcer.E.LoadPolicy()
1640 if err != nil {
1641 l.Error("failed to rollback policies")
1642 }
1643 }()
1644
1645 // remove collaborator RBAC
1646 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1647 if err != nil {
1648 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1649 return
1650 }
1651 for _, c := range repoCollaborators {
1652 did := c[0]
1653 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1654 }
1655 l.Info("removed collaborators")
1656
1657 // remove repo RBAC
1658 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1659 if err != nil {
1660 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1661 return
1662 }
1663
1664 // remove repo from db
1665 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1666 if err != nil {
1667 rp.pages.Notice(w, noticeId, "Failed to update appview")
1668 return
1669 }
1670 l.Info("removed repo from db")
1671
1672 err = tx.Commit()
1673 if err != nil {
1674 l.Error("failed to commit changes", "err", err)
1675 http.Error(w, err.Error(), http.StatusInternalServerError)
1676 return
1677 }
1678
1679 err = rp.enforcer.E.SavePolicy()
1680 if err != nil {
1681 l.Error("failed to update ACLs", "err", err)
1682 http.Error(w, err.Error(), http.StatusInternalServerError)
1683 return
1684 }
1685
1686 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1687}
1688
1689func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
1690 l := rp.logger.With("handler", "EditBaseSettings")
1691
1692 noticeId := "repo-base-settings-error"
1693
1694 f, err := rp.repoResolver.Resolve(r)
1695 if err != nil {
1696 l.Error("failed to get repo and knot", "err", err)
1697 w.WriteHeader(http.StatusBadRequest)
1698 return
1699 }
1700
1701 client, err := rp.oauth.AuthorizedClient(r)
1702 if err != nil {
1703 l.Error("failed to get client")
1704 rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
1705 return
1706 }
1707
1708 var (
1709 description = r.FormValue("description")
1710 website = r.FormValue("website")
1711 topicStr = r.FormValue("topics")
1712 )
1713
1714 err = rp.validator.ValidateURI(website)
1715 if err != nil {
1716 l.Error("invalid uri", "err", err)
1717 rp.pages.Notice(w, noticeId, err.Error())
1718 return
1719 }
1720
1721 topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
1722 if err != nil {
1723 l.Error("invalid topics", "err", err)
1724 rp.pages.Notice(w, noticeId, err.Error())
1725 return
1726 }
1727 l.Debug("got", "topicsStr", topicStr, "topics", topics)
1728
1729 newRepo := f.Repo
1730 newRepo.Description = description
1731 newRepo.Website = website
1732 newRepo.Topics = topics
1733 record := newRepo.AsRecord()
1734
1735 tx, err := rp.db.BeginTx(r.Context(), nil)
1736 if err != nil {
1737 l.Error("failed to begin transaction", "err", err)
1738 rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1739 return
1740 }
1741 defer tx.Rollback()
1742
1743 err = db.PutRepo(tx, newRepo)
1744 if err != nil {
1745 l.Error("failed to update repository", "err", err)
1746 rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1747 return
1748 }
1749
1750 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1751 if err != nil {
1752 // failed to get record
1753 l.Error("failed to get repo record", "err", err)
1754 rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
1755 return
1756 }
1757 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758 Collection: tangled.RepoNSID,
1759 Repo: newRepo.Did,
1760 Rkey: newRepo.Rkey,
1761 SwapRecord: ex.Cid,
1762 Record: &lexutil.LexiconTypeDecoder{
1763 Val: &record,
1764 },
1765 })
1766
1767 if err != nil {
1768 l.Error("failed to perferom update-repo query", "err", err)
1769 // failed to get record
1770 rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
1771 return
1772 }
1773
1774 err = tx.Commit()
1775 if err != nil {
1776 l.Error("failed to commit", "err", err)
1777 }
1778
1779 rp.pages.HxRefresh(w)
1780}
1781
1782func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1783 l := rp.logger.With("handler", "SetDefaultBranch")
1784
1785 f, err := rp.repoResolver.Resolve(r)
1786 if err != nil {
1787 l.Error("failed to get repo and knot", "err", err)
1788 return
1789 }
1790
1791 noticeId := "operation-error"
1792 branch := r.FormValue("branch")
1793 if branch == "" {
1794 http.Error(w, "malformed form", http.StatusBadRequest)
1795 return
1796 }
1797
1798 client, err := rp.oauth.ServiceClient(
1799 r,
1800 oauth.WithService(f.Knot),
1801 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1802 oauth.WithDev(rp.config.Core.Dev),
1803 )
1804 if err != nil {
1805 l.Error("failed to connect to knot server", "err", err)
1806 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1807 return
1808 }
1809
1810 xe := tangled.RepoSetDefaultBranch(
1811 r.Context(),
1812 client,
1813 &tangled.RepoSetDefaultBranch_Input{
1814 Repo: f.RepoAt().String(),
1815 DefaultBranch: branch,
1816 },
1817 )
1818 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1819 l.Error("xrpc failed", "err", xe)
1820 rp.pages.Notice(w, noticeId, err.Error())
1821 return
1822 }
1823
1824 rp.pages.HxRefresh(w)
1825}
1826
1827func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1828 user := rp.oauth.GetUser(r)
1829 l := rp.logger.With("handler", "Secrets")
1830 l = l.With("did", user.Did)
1831
1832 f, err := rp.repoResolver.Resolve(r)
1833 if err != nil {
1834 l.Error("failed to get repo and knot", "err", err)
1835 return
1836 }
1837
1838 if f.Spindle == "" {
1839 l.Error("empty spindle cannot add/rm secret", "err", err)
1840 return
1841 }
1842
1843 lxm := tangled.RepoAddSecretNSID
1844 if r.Method == http.MethodDelete {
1845 lxm = tangled.RepoRemoveSecretNSID
1846 }
1847
1848 spindleClient, err := rp.oauth.ServiceClient(
1849 r,
1850 oauth.WithService(f.Spindle),
1851 oauth.WithLxm(lxm),
1852 oauth.WithExp(60),
1853 oauth.WithDev(rp.config.Core.Dev),
1854 )
1855 if err != nil {
1856 l.Error("failed to create spindle client", "err", err)
1857 return
1858 }
1859
1860 key := r.FormValue("key")
1861 if key == "" {
1862 w.WriteHeader(http.StatusBadRequest)
1863 return
1864 }
1865
1866 switch r.Method {
1867 case http.MethodPut:
1868 errorId := "add-secret-error"
1869
1870 value := r.FormValue("value")
1871 if value == "" {
1872 w.WriteHeader(http.StatusBadRequest)
1873 return
1874 }
1875
1876 err = tangled.RepoAddSecret(
1877 r.Context(),
1878 spindleClient,
1879 &tangled.RepoAddSecret_Input{
1880 Repo: f.RepoAt().String(),
1881 Key: key,
1882 Value: value,
1883 },
1884 )
1885 if err != nil {
1886 l.Error("Failed to add secret.", "err", err)
1887 rp.pages.Notice(w, errorId, "Failed to add secret.")
1888 return
1889 }
1890
1891 case http.MethodDelete:
1892 errorId := "operation-error"
1893
1894 err = tangled.RepoRemoveSecret(
1895 r.Context(),
1896 spindleClient,
1897 &tangled.RepoRemoveSecret_Input{
1898 Repo: f.RepoAt().String(),
1899 Key: key,
1900 },
1901 )
1902 if err != nil {
1903 l.Error("Failed to delete secret.", "err", err)
1904 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1905 return
1906 }
1907 }
1908
1909 rp.pages.HxRefresh(w)
1910}
1911
1912type tab = map[string]any
1913
1914var (
1915 // would be great to have ordered maps right about now
1916 settingsTabs []tab = []tab{
1917 {"Name": "general", "Icon": "sliders-horizontal"},
1918 {"Name": "access", "Icon": "users"},
1919 {"Name": "pipelines", "Icon": "layers-2"},
1920 }
1921)
1922
1923func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1924 tabVal := r.URL.Query().Get("tab")
1925 if tabVal == "" {
1926 tabVal = "general"
1927 }
1928
1929 switch tabVal {
1930 case "general":
1931 rp.generalSettings(w, r)
1932
1933 case "access":
1934 rp.accessSettings(w, r)
1935
1936 case "pipelines":
1937 rp.pipelineSettings(w, r)
1938 }
1939}
1940
1941func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1942 l := rp.logger.With("handler", "generalSettings")
1943
1944 f, err := rp.repoResolver.Resolve(r)
1945 user := rp.oauth.GetUser(r)
1946
1947 scheme := "http"
1948 if !rp.config.Core.Dev {
1949 scheme = "https"
1950 }
1951 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1952 xrpcc := &indigoxrpc.Client{
1953 Host: host,
1954 }
1955
1956 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1957 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1958 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1959 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1960 rp.pages.Error503(w)
1961 return
1962 }
1963
1964 var result types.RepoBranchesResponse
1965 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1966 l.Error("failed to decode XRPC response", "err", err)
1967 rp.pages.Error503(w)
1968 return
1969 }
1970
1971 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1972 if err != nil {
1973 l.Error("failed to fetch labels", "err", err)
1974 rp.pages.Error503(w)
1975 return
1976 }
1977
1978 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1979 if err != nil {
1980 l.Error("failed to fetch labels", "err", err)
1981 rp.pages.Error503(w)
1982 return
1983 }
1984 // remove default labels from the labels list, if present
1985 defaultLabelMap := make(map[string]bool)
1986 for _, dl := range defaultLabels {
1987 defaultLabelMap[dl.AtUri().String()] = true
1988 }
1989 n := 0
1990 for _, l := range labels {
1991 if !defaultLabelMap[l.AtUri().String()] {
1992 labels[n] = l
1993 n++
1994 }
1995 }
1996 labels = labels[:n]
1997
1998 subscribedLabels := make(map[string]struct{})
1999 for _, l := range f.Repo.Labels {
2000 subscribedLabels[l] = struct{}{}
2001 }
2002
2003 // if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2004 // if all default labels are subbed, show the "unsubscribe all" button
2005 shouldSubscribeAll := false
2006 for _, dl := range defaultLabels {
2007 if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2008 // one of the default labels is not subscribed to
2009 shouldSubscribeAll = true
2010 break
2011 }
2012 }
2013
2014 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2015 LoggedInUser: user,
2016 RepoInfo: f.RepoInfo(user),
2017 Branches: result.Branches,
2018 Labels: labels,
2019 DefaultLabels: defaultLabels,
2020 SubscribedLabels: subscribedLabels,
2021 ShouldSubscribeAll: shouldSubscribeAll,
2022 Tabs: settingsTabs,
2023 Tab: "general",
2024 })
2025}
2026
2027func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2028 l := rp.logger.With("handler", "accessSettings")
2029
2030 f, err := rp.repoResolver.Resolve(r)
2031 user := rp.oauth.GetUser(r)
2032
2033 repoCollaborators, err := f.Collaborators(r.Context())
2034 if err != nil {
2035 l.Error("failed to get collaborators", "err", err)
2036 }
2037
2038 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2039 LoggedInUser: user,
2040 RepoInfo: f.RepoInfo(user),
2041 Tabs: settingsTabs,
2042 Tab: "access",
2043 Collaborators: repoCollaborators,
2044 })
2045}
2046
2047func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2048 l := rp.logger.With("handler", "pipelineSettings")
2049
2050 f, err := rp.repoResolver.Resolve(r)
2051 user := rp.oauth.GetUser(r)
2052
2053 // all spindles that the repo owner is a member of
2054 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2055 if err != nil {
2056 l.Error("failed to fetch spindles", "err", err)
2057 return
2058 }
2059
2060 var secrets []*tangled.RepoListSecrets_Secret
2061 if f.Spindle != "" {
2062 if spindleClient, err := rp.oauth.ServiceClient(
2063 r,
2064 oauth.WithService(f.Spindle),
2065 oauth.WithLxm(tangled.RepoListSecretsNSID),
2066 oauth.WithExp(60),
2067 oauth.WithDev(rp.config.Core.Dev),
2068 ); err != nil {
2069 l.Error("failed to create spindle client", "err", err)
2070 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2071 l.Error("failed to fetch secrets", "err", err)
2072 } else {
2073 secrets = resp.Secrets
2074 }
2075 }
2076
2077 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2078 return strings.Compare(a.Key, b.Key)
2079 })
2080
2081 var dids []string
2082 for _, s := range secrets {
2083 dids = append(dids, s.CreatedBy)
2084 }
2085 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2086
2087 // convert to a more manageable form
2088 var niceSecret []map[string]any
2089 for id, s := range secrets {
2090 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2091 niceSecret = append(niceSecret, map[string]any{
2092 "Id": id,
2093 "Key": s.Key,
2094 "CreatedAt": when,
2095 "CreatedBy": resolvedIdents[id].Handle.String(),
2096 })
2097 }
2098
2099 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2100 LoggedInUser: user,
2101 RepoInfo: f.RepoInfo(user),
2102 Tabs: settingsTabs,
2103 Tab: "pipelines",
2104 Spindles: spindles,
2105 CurrentSpindle: f.Spindle,
2106 Secrets: niceSecret,
2107 })
2108}
2109
2110func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2111 l := rp.logger.With("handler", "SyncRepoFork")
2112
2113 ref := chi.URLParam(r, "ref")
2114 ref, _ = url.PathUnescape(ref)
2115
2116 user := rp.oauth.GetUser(r)
2117 f, err := rp.repoResolver.Resolve(r)
2118 if err != nil {
2119 l.Error("failed to resolve source repo", "err", err)
2120 return
2121 }
2122
2123 switch r.Method {
2124 case http.MethodPost:
2125 client, err := rp.oauth.ServiceClient(
2126 r,
2127 oauth.WithService(f.Knot),
2128 oauth.WithLxm(tangled.RepoForkSyncNSID),
2129 oauth.WithDev(rp.config.Core.Dev),
2130 )
2131 if err != nil {
2132 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
2133 return
2134 }
2135
2136 repoInfo := f.RepoInfo(user)
2137 if repoInfo.Source == nil {
2138 rp.pages.Notice(w, "repo", "This repository is not a fork.")
2139 return
2140 }
2141
2142 err = tangled.RepoForkSync(
2143 r.Context(),
2144 client,
2145 &tangled.RepoForkSync_Input{
2146 Did: user.Did,
2147 Name: f.Name,
2148 Source: repoInfo.Source.RepoAt().String(),
2149 Branch: ref,
2150 },
2151 )
2152 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2153 rp.pages.Notice(w, "repo", err.Error())
2154 return
2155 }
2156
2157 rp.pages.HxRefresh(w)
2158 return
2159 }
2160}
2161
2162func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
2163 l := rp.logger.With("handler", "ForkRepo")
2164
2165 user := rp.oauth.GetUser(r)
2166 f, err := rp.repoResolver.Resolve(r)
2167 if err != nil {
2168 l.Error("failed to resolve source repo", "err", err)
2169 return
2170 }
2171
2172 switch r.Method {
2173 case http.MethodGet:
2174 user := rp.oauth.GetUser(r)
2175 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
2176 if err != nil {
2177 rp.pages.Notice(w, "repo", "Invalid user account.")
2178 return
2179 }
2180
2181 rp.pages.ForkRepo(w, pages.ForkRepoParams{
2182 LoggedInUser: user,
2183 Knots: knots,
2184 RepoInfo: f.RepoInfo(user),
2185 })
2186
2187 case http.MethodPost:
2188 l := rp.logger.With("handler", "ForkRepo")
2189
2190 targetKnot := r.FormValue("knot")
2191 if targetKnot == "" {
2192 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
2193 return
2194 }
2195 l = l.With("targetKnot", targetKnot)
2196
2197 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
2198 if err != nil || !ok {
2199 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
2200 return
2201 }
2202
2203 // choose a name for a fork
2204 forkName := r.FormValue("repo_name")
2205 if forkName == "" {
2206 rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2207 return
2208 }
2209
2210 // this check is *only* to see if the forked repo name already exists
2211 // in the user's account.
2212 existingRepo, err := db.GetRepo(
2213 rp.db,
2214 db.FilterEq("did", user.Did),
2215 db.FilterEq("name", forkName),
2216 )
2217 if err != nil {
2218 if !errors.Is(err, sql.ErrNoRows) {
2219 l.Error("error fetching existing repo from db", "err", err)
2220 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2221 return
2222 }
2223 } else if existingRepo != nil {
2224 // repo with this name already exists
2225 rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2226 return
2227 }
2228 l = l.With("forkName", forkName)
2229
2230 uri := "https"
2231 if rp.config.Core.Dev {
2232 uri = "http"
2233 }
2234
2235 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
2236 l = l.With("cloneUrl", forkSourceUrl)
2237
2238 sourceAt := f.RepoAt().String()
2239
2240 // create an atproto record for this fork
2241 rkey := tid.TID()
2242 repo := &models.Repo{
2243 Did: user.Did,
2244 Name: forkName,
2245 Knot: targetKnot,
2246 Rkey: rkey,
2247 Source: sourceAt,
2248 Description: f.Repo.Description,
2249 Created: time.Now(),
2250 Labels: models.DefaultLabelDefs(),
2251 }
2252 record := repo.AsRecord()
2253
2254 atpClient, err := rp.oauth.AuthorizedClient(r)
2255 if err != nil {
2256 l.Error("failed to create xrpcclient", "err", err)
2257 rp.pages.Notice(w, "repo", "Failed to fork repository.")
2258 return
2259 }
2260
2261 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2262 Collection: tangled.RepoNSID,
2263 Repo: user.Did,
2264 Rkey: rkey,
2265 Record: &lexutil.LexiconTypeDecoder{
2266 Val: &record,
2267 },
2268 })
2269 if err != nil {
2270 l.Error("failed to write to PDS", "err", err)
2271 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
2272 return
2273 }
2274
2275 aturi := atresp.Uri
2276 l = l.With("aturi", aturi)
2277 l.Info("wrote to PDS")
2278
2279 tx, err := rp.db.BeginTx(r.Context(), nil)
2280 if err != nil {
2281 l.Info("txn failed", "err", err)
2282 rp.pages.Notice(w, "repo", "Failed to save repository information.")
2283 return
2284 }
2285
2286 // The rollback function reverts a few things on failure:
2287 // - the pending txn
2288 // - the ACLs
2289 // - the atproto record created
2290 rollback := func() {
2291 err1 := tx.Rollback()
2292 err2 := rp.enforcer.E.LoadPolicy()
2293 err3 := rollbackRecord(context.Background(), aturi, atpClient)
2294
2295 // ignore txn complete errors, this is okay
2296 if errors.Is(err1, sql.ErrTxDone) {
2297 err1 = nil
2298 }
2299
2300 if errs := errors.Join(err1, err2, err3); errs != nil {
2301 l.Error("failed to rollback changes", "errs", errs)
2302 return
2303 }
2304 }
2305 defer rollback()
2306
2307 client, err := rp.oauth.ServiceClient(
2308 r,
2309 oauth.WithService(targetKnot),
2310 oauth.WithLxm(tangled.RepoCreateNSID),
2311 oauth.WithDev(rp.config.Core.Dev),
2312 )
2313 if err != nil {
2314 l.Error("could not create service client", "err", err)
2315 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
2316 return
2317 }
2318
2319 err = tangled.RepoCreate(
2320 r.Context(),
2321 client,
2322 &tangled.RepoCreate_Input{
2323 Rkey: rkey,
2324 Source: &forkSourceUrl,
2325 },
2326 )
2327 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2328 rp.pages.Notice(w, "repo", err.Error())
2329 return
2330 }
2331
2332 err = db.AddRepo(tx, repo)
2333 if err != nil {
2334 l.Error("failed to AddRepo", "err", err)
2335 rp.pages.Notice(w, "repo", "Failed to save repository information.")
2336 return
2337 }
2338
2339 // acls
2340 p, _ := securejoin.SecureJoin(user.Did, forkName)
2341 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
2342 if err != nil {
2343 l.Error("failed to add ACLs", "err", err)
2344 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2345 return
2346 }
2347
2348 err = tx.Commit()
2349 if err != nil {
2350 l.Error("failed to commit changes", "err", err)
2351 http.Error(w, err.Error(), http.StatusInternalServerError)
2352 return
2353 }
2354
2355 err = rp.enforcer.E.SavePolicy()
2356 if err != nil {
2357 l.Error("failed to update ACLs", "err", err)
2358 http.Error(w, err.Error(), http.StatusInternalServerError)
2359 return
2360 }
2361
2362 // reset the ATURI because the transaction completed successfully
2363 aturi = ""
2364
2365 rp.notifier.NewRepo(r.Context(), repo)
2366 rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2367 }
2368}
2369
2370// this is used to rollback changes made to the PDS
2371//
2372// it is a no-op if the provided ATURI is empty
2373func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2374 if aturi == "" {
2375 return nil
2376 }
2377
2378 parsed := syntax.ATURI(aturi)
2379
2380 collection := parsed.Collection().String()
2381 repo := parsed.Authority().String()
2382 rkey := parsed.RecordKey().String()
2383
2384 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2385 Collection: collection,
2386 Repo: repo,
2387 Rkey: rkey,
2388 })
2389 return err
2390}
2391
2392func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2393 l := rp.logger.With("handler", "RepoCompareNew")
2394
2395 user := rp.oauth.GetUser(r)
2396 f, err := rp.repoResolver.Resolve(r)
2397 if err != nil {
2398 l.Error("failed to get repo and knot", "err", err)
2399 return
2400 }
2401
2402 scheme := "http"
2403 if !rp.config.Core.Dev {
2404 scheme = "https"
2405 }
2406 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2407 xrpcc := &indigoxrpc.Client{
2408 Host: host,
2409 }
2410
2411 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2412 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2413 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2414 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2415 rp.pages.Error503(w)
2416 return
2417 }
2418
2419 var branchResult types.RepoBranchesResponse
2420 if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2421 l.Error("failed to decode XRPC branches response", "err", err)
2422 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2423 return
2424 }
2425 branches := branchResult.Branches
2426
2427 sortBranches(branches)
2428
2429 var defaultBranch string
2430 for _, b := range branches {
2431 if b.IsDefault {
2432 defaultBranch = b.Name
2433 }
2434 }
2435
2436 base := defaultBranch
2437 head := defaultBranch
2438
2439 params := r.URL.Query()
2440 queryBase := params.Get("base")
2441 queryHead := params.Get("head")
2442 if queryBase != "" {
2443 base = queryBase
2444 }
2445 if queryHead != "" {
2446 head = queryHead
2447 }
2448
2449 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2450 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2451 l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2452 rp.pages.Error503(w)
2453 return
2454 }
2455
2456 var tags types.RepoTagsResponse
2457 if err := json.Unmarshal(tagBytes, &tags); err != nil {
2458 l.Error("failed to decode XRPC tags response", "err", err)
2459 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2460 return
2461 }
2462
2463 repoinfo := f.RepoInfo(user)
2464
2465 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2466 LoggedInUser: user,
2467 RepoInfo: repoinfo,
2468 Branches: branches,
2469 Tags: tags.Tags,
2470 Base: base,
2471 Head: head,
2472 })
2473}
2474
2475func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2476 l := rp.logger.With("handler", "RepoCompare")
2477
2478 user := rp.oauth.GetUser(r)
2479 f, err := rp.repoResolver.Resolve(r)
2480 if err != nil {
2481 l.Error("failed to get repo and knot", "err", err)
2482 return
2483 }
2484
2485 var diffOpts types.DiffOpts
2486 if d := r.URL.Query().Get("diff"); d == "split" {
2487 diffOpts.Split = true
2488 }
2489
2490 // if user is navigating to one of
2491 // /compare/{base}/{head}
2492 // /compare/{base}...{head}
2493 base := chi.URLParam(r, "base")
2494 head := chi.URLParam(r, "head")
2495 if base == "" && head == "" {
2496 rest := chi.URLParam(r, "*") // master...feature/xyz
2497 parts := strings.SplitN(rest, "...", 2)
2498 if len(parts) == 2 {
2499 base = parts[0]
2500 head = parts[1]
2501 }
2502 }
2503
2504 base, _ = url.PathUnescape(base)
2505 head, _ = url.PathUnescape(head)
2506
2507 if base == "" || head == "" {
2508 l.Error("invalid comparison")
2509 rp.pages.Error404(w)
2510 return
2511 }
2512
2513 scheme := "http"
2514 if !rp.config.Core.Dev {
2515 scheme = "https"
2516 }
2517 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2518 xrpcc := &indigoxrpc.Client{
2519 Host: host,
2520 }
2521
2522 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2523
2524 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2525 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2526 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2527 rp.pages.Error503(w)
2528 return
2529 }
2530
2531 var branches types.RepoBranchesResponse
2532 if err := json.Unmarshal(branchBytes, &branches); err != nil {
2533 l.Error("failed to decode XRPC branches response", "err", err)
2534 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2535 return
2536 }
2537
2538 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2539 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2540 l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2541 rp.pages.Error503(w)
2542 return
2543 }
2544
2545 var tags types.RepoTagsResponse
2546 if err := json.Unmarshal(tagBytes, &tags); err != nil {
2547 l.Error("failed to decode XRPC tags response", "err", err)
2548 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2549 return
2550 }
2551
2552 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2553 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2554 l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2555 rp.pages.Error503(w)
2556 return
2557 }
2558
2559 var formatPatch types.RepoFormatPatchResponse
2560 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2561 l.Error("failed to decode XRPC compare response", "err", err)
2562 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2563 return
2564 }
2565
2566 var diff types.NiceDiff
2567 if formatPatch.CombinedPatchRaw != "" {
2568 diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2569 } else {
2570 diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2571 }
2572
2573 repoinfo := f.RepoInfo(user)
2574
2575 rp.pages.RepoCompare(w, pages.RepoCompareParams{
2576 LoggedInUser: user,
2577 RepoInfo: repoinfo,
2578 Branches: branches.Branches,
2579 Tags: tags.Tags,
2580 Base: base,
2581 Head: head,
2582 Diff: &diff,
2583 DiffOpts: diffOpts,
2584 })
2585
2586}