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