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