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