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