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