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