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