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 "tangled.sh/tangled.sh/core/api/tangled"
23 "tangled.sh/tangled.sh/core/appview/commitverify"
24 "tangled.sh/tangled.sh/core/appview/config"
25 "tangled.sh/tangled.sh/core/appview/db"
26 "tangled.sh/tangled.sh/core/appview/notify"
27 "tangled.sh/tangled.sh/core/appview/oauth"
28 "tangled.sh/tangled.sh/core/appview/pages"
29 "tangled.sh/tangled.sh/core/appview/pages/markup"
30 "tangled.sh/tangled.sh/core/appview/reporesolver"
31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32 "tangled.sh/tangled.sh/core/eventconsumer"
33 "tangled.sh/tangled.sh/core/idresolver"
34 "tangled.sh/tangled.sh/core/knotclient"
35 "tangled.sh/tangled.sh/core/patchutil"
36 "tangled.sh/tangled.sh/core/rbac"
37 "tangled.sh/tangled.sh/core/tid"
38 "tangled.sh/tangled.sh/core/types"
39 "tangled.sh/tangled.sh/core/xrpc/serviceauth"
40
41 securejoin "github.com/cyphar/filepath-securejoin"
42 "github.com/go-chi/chi/v5"
43 "github.com/go-git/go-git/v5/plumbing"
44
45 "github.com/bluesky-social/indigo/atproto/syntax"
46)
47
48type Repo struct {
49 repoResolver *reporesolver.RepoResolver
50 idResolver *idresolver.Resolver
51 config *config.Config
52 oauth *oauth.OAuth
53 pages *pages.Pages
54 spindlestream *eventconsumer.Consumer
55 db *db.DB
56 enforcer *rbac.Enforcer
57 notifier notify.Notifier
58 logger *slog.Logger
59 serviceAuth *serviceauth.ServiceAuth
60}
61
62func New(
63 oauth *oauth.OAuth,
64 repoResolver *reporesolver.RepoResolver,
65 pages *pages.Pages,
66 spindlestream *eventconsumer.Consumer,
67 idResolver *idresolver.Resolver,
68 db *db.DB,
69 config *config.Config,
70 notifier notify.Notifier,
71 enforcer *rbac.Enforcer,
72 logger *slog.Logger,
73) *Repo {
74 return &Repo{oauth: oauth,
75 repoResolver: repoResolver,
76 pages: pages,
77 idResolver: idResolver,
78 config: config,
79 spindlestream: spindlestream,
80 db: db,
81 notifier: notifier,
82 enforcer: enforcer,
83 logger: logger,
84 }
85}
86
87func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
88 refParam := chi.URLParam(r, "ref")
89 f, err := rp.repoResolver.Resolve(r)
90 if err != nil {
91 log.Println("failed to get repo and knot", err)
92 return
93 }
94
95 var uri string
96 if rp.config.Core.Dev {
97 uri = "http"
98 } else {
99 uri = "https"
100 }
101 url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
102
103 http.Redirect(w, r, url, http.StatusFound)
104}
105
106func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
107 f, err := rp.repoResolver.Resolve(r)
108 if err != nil {
109 log.Println("failed to fully resolve repo", err)
110 return
111 }
112
113 page := 1
114 if r.URL.Query().Get("page") != "" {
115 page, err = strconv.Atoi(r.URL.Query().Get("page"))
116 if err != nil {
117 page = 1
118 }
119 }
120
121 ref := chi.URLParam(r, "ref")
122
123 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
124 if err != nil {
125 log.Println("failed to create unsigned client", err)
126 return
127 }
128
129 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
130 if err != nil {
131 rp.pages.Error503(w)
132 log.Println("failed to reach knotserver", err)
133 return
134 }
135
136 tagResult, err := us.Tags(f.OwnerDid(), f.Name)
137 if err != nil {
138 rp.pages.Error503(w)
139 log.Println("failed to reach knotserver", err)
140 return
141 }
142
143 tagMap := make(map[string][]string)
144 for _, tag := range tagResult.Tags {
145 hash := tag.Hash
146 if tag.Tag != nil {
147 hash = tag.Tag.Target.String()
148 }
149 tagMap[hash] = append(tagMap[hash], tag.Name)
150 }
151
152 branchResult, err := us.Branches(f.OwnerDid(), f.Name)
153 if err != nil {
154 rp.pages.Error503(w)
155 log.Println("failed to reach knotserver", err)
156 return
157 }
158
159 for _, branch := range branchResult.Branches {
160 hash := branch.Hash
161 tagMap[hash] = append(tagMap[hash], branch.Name)
162 }
163
164 user := rp.oauth.GetUser(r)
165
166 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
167 if err != nil {
168 log.Println("failed to fetch email to did mapping", err)
169 }
170
171 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
172 if err != nil {
173 log.Println(err)
174 }
175
176 repoInfo := f.RepoInfo(user)
177
178 var shas []string
179 for _, c := range repolog.Commits {
180 shas = append(shas, c.Hash.String())
181 }
182 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
183 if err != nil {
184 log.Println(err)
185 // non-fatal
186 }
187
188 rp.pages.RepoLog(w, pages.RepoLogParams{
189 LoggedInUser: user,
190 TagMap: tagMap,
191 RepoInfo: repoInfo,
192 RepoLogResponse: *repolog,
193 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
194 VerifiedCommits: vc,
195 Pipelines: pipelines,
196 })
197}
198
199func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
200 f, err := rp.repoResolver.Resolve(r)
201 if err != nil {
202 log.Println("failed to get repo and knot", err)
203 w.WriteHeader(http.StatusBadRequest)
204 return
205 }
206
207 user := rp.oauth.GetUser(r)
208 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
209 RepoInfo: f.RepoInfo(user),
210 })
211}
212
213func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
214 f, err := rp.repoResolver.Resolve(r)
215 if err != nil {
216 log.Println("failed to get repo and knot", err)
217 w.WriteHeader(http.StatusBadRequest)
218 return
219 }
220
221 repoAt := f.RepoAt()
222 rkey := repoAt.RecordKey().String()
223 if rkey == "" {
224 log.Println("invalid aturi for repo", err)
225 w.WriteHeader(http.StatusInternalServerError)
226 return
227 }
228
229 user := rp.oauth.GetUser(r)
230
231 switch r.Method {
232 case http.MethodGet:
233 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
234 RepoInfo: f.RepoInfo(user),
235 })
236 return
237 case http.MethodPut:
238 newDescription := r.FormValue("description")
239 client, err := rp.oauth.AuthorizedClient(r)
240 if err != nil {
241 log.Println("failed to get client")
242 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
243 return
244 }
245
246 // optimistic update
247 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
248 if err != nil {
249 log.Println("failed to perferom update-description query", err)
250 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
251 return
252 }
253
254 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
255 //
256 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
257 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
258 if err != nil {
259 // failed to get record
260 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
261 return
262 }
263 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
264 Collection: tangled.RepoNSID,
265 Repo: user.Did,
266 Rkey: rkey,
267 SwapRecord: ex.Cid,
268 Record: &lexutil.LexiconTypeDecoder{
269 Val: &tangled.Repo{
270 Knot: f.Knot,
271 Name: f.Name,
272 Owner: user.Did,
273 CreatedAt: f.Created.Format(time.RFC3339),
274 Description: &newDescription,
275 Spindle: &f.Spindle,
276 },
277 },
278 })
279
280 if err != nil {
281 log.Println("failed to perferom update-description query", err)
282 // failed to get record
283 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
284 return
285 }
286
287 newRepoInfo := f.RepoInfo(user)
288 newRepoInfo.Description = newDescription
289
290 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
291 RepoInfo: newRepoInfo,
292 })
293 return
294 }
295}
296
297func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
298 f, err := rp.repoResolver.Resolve(r)
299 if err != nil {
300 log.Println("failed to fully resolve repo", err)
301 return
302 }
303 ref := chi.URLParam(r, "ref")
304 protocol := "http"
305 if !rp.config.Core.Dev {
306 protocol = "https"
307 }
308
309 var diffOpts types.DiffOpts
310 if d := r.URL.Query().Get("diff"); d == "split" {
311 diffOpts.Split = true
312 }
313
314 if !plumbing.IsHash(ref) {
315 rp.pages.Error404(w)
316 return
317 }
318
319 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
320 if err != nil {
321 rp.pages.Error503(w)
322 log.Println("failed to reach knotserver", err)
323 return
324 }
325
326 body, err := io.ReadAll(resp.Body)
327 if err != nil {
328 log.Printf("Error reading response body: %v", err)
329 return
330 }
331
332 var result types.RepoCommitResponse
333 err = json.Unmarshal(body, &result)
334 if err != nil {
335 log.Println("failed to parse response:", err)
336 return
337 }
338
339 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
340 if err != nil {
341 log.Println("failed to get email to did mapping:", err)
342 }
343
344 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
345 if err != nil {
346 log.Println(err)
347 }
348
349 user := rp.oauth.GetUser(r)
350 repoInfo := f.RepoInfo(user)
351 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
352 if err != nil {
353 log.Println(err)
354 // non-fatal
355 }
356 var pipeline *db.Pipeline
357 if p, ok := pipelines[result.Diff.Commit.This]; ok {
358 pipeline = &p
359 }
360
361 rp.pages.RepoCommit(w, pages.RepoCommitParams{
362 LoggedInUser: user,
363 RepoInfo: f.RepoInfo(user),
364 RepoCommitResponse: result,
365 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
366 VerifiedCommit: vc,
367 Pipeline: pipeline,
368 DiffOpts: diffOpts,
369 })
370}
371
372func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
373 f, err := rp.repoResolver.Resolve(r)
374 if err != nil {
375 log.Println("failed to fully resolve repo", err)
376 return
377 }
378
379 ref := chi.URLParam(r, "ref")
380 treePath := chi.URLParam(r, "*")
381 protocol := "http"
382 if !rp.config.Core.Dev {
383 protocol = "https"
384 }
385
386 // if the tree path has a trailing slash, let's strip it
387 // so we don't 404
388 treePath = strings.TrimSuffix(treePath, "/")
389
390 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
391 if err != nil {
392 rp.pages.Error503(w)
393 log.Println("failed to reach knotserver", err)
394 return
395 }
396
397 // uhhh so knotserver returns a 500 if the entry isn't found in
398 // the requested tree path, so let's stick to not-OK here.
399 // we can fix this once we build out the xrpc apis for these operations.
400 if resp.StatusCode != http.StatusOK {
401 rp.pages.Error404(w)
402 return
403 }
404
405 body, err := io.ReadAll(resp.Body)
406 if err != nil {
407 log.Printf("Error reading response body: %v", err)
408 return
409 }
410
411 var result types.RepoTreeResponse
412 err = json.Unmarshal(body, &result)
413 if err != nil {
414 log.Println("failed to parse response:", err)
415 return
416 }
417
418 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
419 // so we can safely redirect to the "parent" (which is the same file).
420 unescapedTreePath, _ := url.PathUnescape(treePath)
421 if len(result.Files) == 0 && result.Parent == unescapedTreePath {
422 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
423 return
424 }
425
426 user := rp.oauth.GetUser(r)
427
428 var breadcrumbs [][]string
429 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
430 if treePath != "" {
431 for idx, elem := range strings.Split(treePath, "/") {
432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
433 }
434 }
435
436 sortFiles(result.Files)
437
438 rp.pages.RepoTree(w, pages.RepoTreeParams{
439 LoggedInUser: user,
440 BreadCrumbs: breadcrumbs,
441 TreePath: treePath,
442 RepoInfo: f.RepoInfo(user),
443 RepoTreeResponse: result,
444 })
445}
446
447func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
448 f, err := rp.repoResolver.Resolve(r)
449 if err != nil {
450 log.Println("failed to get repo and knot", err)
451 return
452 }
453
454 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
455 if err != nil {
456 log.Println("failed to create unsigned client", err)
457 return
458 }
459
460 result, err := us.Tags(f.OwnerDid(), f.Name)
461 if err != nil {
462 rp.pages.Error503(w)
463 log.Println("failed to reach knotserver", err)
464 return
465 }
466
467 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
468 if err != nil {
469 log.Println("failed grab artifacts", err)
470 return
471 }
472
473 // convert artifacts to map for easy UI building
474 artifactMap := make(map[plumbing.Hash][]db.Artifact)
475 for _, a := range artifacts {
476 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
477 }
478
479 var danglingArtifacts []db.Artifact
480 for _, a := range artifacts {
481 found := false
482 for _, t := range result.Tags {
483 if t.Tag != nil {
484 if t.Tag.Hash == a.Tag {
485 found = true
486 }
487 }
488 }
489
490 if !found {
491 danglingArtifacts = append(danglingArtifacts, a)
492 }
493 }
494
495 user := rp.oauth.GetUser(r)
496 rp.pages.RepoTags(w, pages.RepoTagsParams{
497 LoggedInUser: user,
498 RepoInfo: f.RepoInfo(user),
499 RepoTagsResponse: *result,
500 ArtifactMap: artifactMap,
501 DanglingArtifacts: danglingArtifacts,
502 })
503}
504
505func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
506 f, err := rp.repoResolver.Resolve(r)
507 if err != nil {
508 log.Println("failed to get repo and knot", err)
509 return
510 }
511
512 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
513 if err != nil {
514 log.Println("failed to create unsigned client", err)
515 return
516 }
517
518 result, err := us.Branches(f.OwnerDid(), f.Name)
519 if err != nil {
520 rp.pages.Error503(w)
521 log.Println("failed to reach knotserver", err)
522 return
523 }
524
525 sortBranches(result.Branches)
526
527 user := rp.oauth.GetUser(r)
528 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
529 LoggedInUser: user,
530 RepoInfo: f.RepoInfo(user),
531 RepoBranchesResponse: *result,
532 })
533}
534
535func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
536 f, err := rp.repoResolver.Resolve(r)
537 if err != nil {
538 log.Println("failed to get repo and knot", err)
539 return
540 }
541
542 ref := chi.URLParam(r, "ref")
543 filePath := chi.URLParam(r, "*")
544 protocol := "http"
545 if !rp.config.Core.Dev {
546 protocol = "https"
547 }
548 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
549 if err != nil {
550 rp.pages.Error503(w)
551 log.Println("failed to reach knotserver", err)
552 return
553 }
554
555 if resp.StatusCode == http.StatusNotFound {
556 rp.pages.Error404(w)
557 return
558 }
559
560 body, err := io.ReadAll(resp.Body)
561 if err != nil {
562 log.Printf("Error reading response body: %v", err)
563 return
564 }
565
566 var result types.RepoBlobResponse
567 err = json.Unmarshal(body, &result)
568 if err != nil {
569 log.Println("failed to parse response:", err)
570 return
571 }
572
573 var breadcrumbs [][]string
574 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
575 if filePath != "" {
576 for idx, elem := range strings.Split(filePath, "/") {
577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
578 }
579 }
580
581 showRendered := false
582 renderToggle := false
583
584 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
585 renderToggle = true
586 showRendered = r.URL.Query().Get("code") != "true"
587 }
588
589 var unsupported bool
590 var isImage bool
591 var isVideo bool
592 var contentSrc string
593
594 if result.IsBinary {
595 ext := strings.ToLower(filepath.Ext(result.Path))
596 switch ext {
597 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
598 isImage = true
599 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
600 isVideo = true
601 default:
602 unsupported = true
603 }
604
605 // fetch the actual binary content like in RepoBlobRaw
606
607 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
608 contentSrc = blobURL
609 if !rp.config.Core.Dev {
610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
611 }
612 }
613
614 user := rp.oauth.GetUser(r)
615 rp.pages.RepoBlob(w, pages.RepoBlobParams{
616 LoggedInUser: user,
617 RepoInfo: f.RepoInfo(user),
618 RepoBlobResponse: result,
619 BreadCrumbs: breadcrumbs,
620 ShowRendered: showRendered,
621 RenderToggle: renderToggle,
622 Unsupported: unsupported,
623 IsImage: isImage,
624 IsVideo: isVideo,
625 ContentSrc: contentSrc,
626 })
627}
628
629func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
630 f, err := rp.repoResolver.Resolve(r)
631 if err != nil {
632 log.Println("failed to get repo and knot", err)
633 w.WriteHeader(http.StatusBadRequest)
634 return
635 }
636
637 ref := chi.URLParam(r, "ref")
638 filePath := chi.URLParam(r, "*")
639
640 protocol := "http"
641 if !rp.config.Core.Dev {
642 protocol = "https"
643 }
644
645 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
646
647 req, err := http.NewRequest("GET", blobURL, nil)
648 if err != nil {
649 log.Println("failed to create request", err)
650 return
651 }
652
653 // forward the If-None-Match header
654 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
655 req.Header.Set("If-None-Match", clientETag)
656 }
657
658 client := &http.Client{}
659 resp, err := client.Do(req)
660 if err != nil {
661 log.Println("failed to reach knotserver", err)
662 rp.pages.Error503(w)
663 return
664 }
665 defer resp.Body.Close()
666
667 // forward 304 not modified
668 if resp.StatusCode == http.StatusNotModified {
669 w.WriteHeader(http.StatusNotModified)
670 return
671 }
672
673 if resp.StatusCode != http.StatusOK {
674 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
675 w.WriteHeader(resp.StatusCode)
676 _, _ = io.Copy(w, resp.Body)
677 return
678 }
679
680 contentType := resp.Header.Get("Content-Type")
681 body, err := io.ReadAll(resp.Body)
682 if err != nil {
683 log.Printf("error reading response body from knotserver: %v", err)
684 w.WriteHeader(http.StatusInternalServerError)
685 return
686 }
687
688 if strings.Contains(contentType, "text/plain") {
689 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
690 w.Write(body)
691 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
692 w.Header().Set("Content-Type", contentType)
693 w.Write(body)
694 } else {
695 w.WriteHeader(http.StatusUnsupportedMediaType)
696 w.Write([]byte("unsupported content type"))
697 return
698 }
699}
700
701// modify the spindle configured for this repo
702func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
703 user := rp.oauth.GetUser(r)
704 l := rp.logger.With("handler", "EditSpindle")
705 l = l.With("did", user.Did)
706 l = l.With("handle", user.Handle)
707
708 errorId := "operation-error"
709 fail := func(msg string, err error) {
710 l.Error(msg, "err", err)
711 rp.pages.Notice(w, errorId, msg)
712 }
713
714 f, err := rp.repoResolver.Resolve(r)
715 if err != nil {
716 fail("Failed to resolve repo. Try again later", err)
717 return
718 }
719
720 repoAt := f.RepoAt()
721 rkey := repoAt.RecordKey().String()
722 if rkey == "" {
723 fail("Failed to resolve repo. Try again later", err)
724 return
725 }
726
727 newSpindle := r.FormValue("spindle")
728 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
729 client, err := rp.oauth.AuthorizedClient(r)
730 if err != nil {
731 fail("Failed to authorize. Try again later.", err)
732 return
733 }
734
735 if !removingSpindle {
736 // ensure that this is a valid spindle for this user
737 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
738 if err != nil {
739 fail("Failed to find spindles. Try again later.", err)
740 return
741 }
742
743 if !slices.Contains(validSpindles, newSpindle) {
744 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
745 return
746 }
747 }
748
749 spindlePtr := &newSpindle
750 if removingSpindle {
751 spindlePtr = nil
752 }
753
754 // optimistic update
755 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
756 if err != nil {
757 fail("Failed to update spindle. Try again later.", err)
758 return
759 }
760
761 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
762 if err != nil {
763 fail("Failed to update spindle, no record found on PDS.", err)
764 return
765 }
766 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
767 Collection: tangled.RepoNSID,
768 Repo: user.Did,
769 Rkey: rkey,
770 SwapRecord: ex.Cid,
771 Record: &lexutil.LexiconTypeDecoder{
772 Val: &tangled.Repo{
773 Knot: f.Knot,
774 Name: f.Name,
775 Owner: user.Did,
776 CreatedAt: f.Created.Format(time.RFC3339),
777 Description: &f.Description,
778 Spindle: spindlePtr,
779 },
780 },
781 })
782
783 if err != nil {
784 fail("Failed to update spindle, unable to save to PDS.", err)
785 return
786 }
787
788 if !removingSpindle {
789 // add this spindle to spindle stream
790 rp.spindlestream.AddSource(
791 context.Background(),
792 eventconsumer.NewSpindleSource(newSpindle),
793 )
794 }
795
796 rp.pages.HxRefresh(w)
797}
798
799func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
800 user := rp.oauth.GetUser(r)
801 l := rp.logger.With("handler", "AddCollaborator")
802 l = l.With("did", user.Did)
803 l = l.With("handle", user.Handle)
804
805 f, err := rp.repoResolver.Resolve(r)
806 if err != nil {
807 l.Error("failed to get repo and knot", "err", err)
808 return
809 }
810
811 errorId := "add-collaborator-error"
812 fail := func(msg string, err error) {
813 l.Error(msg, "err", err)
814 rp.pages.Notice(w, errorId, msg)
815 }
816
817 collaborator := r.FormValue("collaborator")
818 if collaborator == "" {
819 fail("Invalid form.", nil)
820 return
821 }
822
823 // remove a single leading `@`, to make @handle work with ResolveIdent
824 collaborator = strings.TrimPrefix(collaborator, "@")
825
826 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
827 if err != nil {
828 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
829 return
830 }
831
832 if collaboratorIdent.DID.String() == user.Did {
833 fail("You seem to be adding yourself as a collaborator.", nil)
834 return
835 }
836 l = l.With("collaborator", collaboratorIdent.Handle)
837 l = l.With("knot", f.Knot)
838
839 // announce this relation into the firehose, store into owners' pds
840 client, err := rp.oauth.AuthorizedClient(r)
841 if err != nil {
842 fail("Failed to write to PDS.", err)
843 return
844 }
845
846 // emit a record
847 currentUser := rp.oauth.GetUser(r)
848 rkey := tid.TID()
849 createdAt := time.Now()
850 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
851 Collection: tangled.RepoCollaboratorNSID,
852 Repo: currentUser.Did,
853 Rkey: rkey,
854 Record: &lexutil.LexiconTypeDecoder{
855 Val: &tangled.RepoCollaborator{
856 Subject: collaboratorIdent.DID.String(),
857 Repo: string(f.RepoAt()),
858 CreatedAt: createdAt.Format(time.RFC3339),
859 }},
860 })
861 // invalid record
862 if err != nil {
863 fail("Failed to write record to PDS.", err)
864 return
865 }
866
867 aturi := resp.Uri
868 l = l.With("at-uri", aturi)
869 l.Info("wrote record to PDS")
870
871 tx, err := rp.db.BeginTx(r.Context(), nil)
872 if err != nil {
873 fail("Failed to add collaborator.", err)
874 return
875 }
876
877 rollback := func() {
878 err1 := tx.Rollback()
879 err2 := rp.enforcer.E.LoadPolicy()
880 err3 := rollbackRecord(context.Background(), aturi, client)
881
882 // ignore txn complete errors, this is okay
883 if errors.Is(err1, sql.ErrTxDone) {
884 err1 = nil
885 }
886
887 if errs := errors.Join(err1, err2, err3); errs != nil {
888 l.Error("failed to rollback changes", "errs", errs)
889 return
890 }
891 }
892 defer rollback()
893
894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
895 if err != nil {
896 fail("Failed to add collaborator permissions.", err)
897 return
898 }
899
900 err = db.AddCollaborator(rp.db, db.Collaborator{
901 Did: syntax.DID(currentUser.Did),
902 Rkey: rkey,
903 SubjectDid: collaboratorIdent.DID,
904 RepoAt: f.RepoAt(),
905 Created: createdAt,
906 })
907 if err != nil {
908 fail("Failed to add collaborator.", err)
909 return
910 }
911
912 err = tx.Commit()
913 if err != nil {
914 fail("Failed to add collaborator.", err)
915 return
916 }
917
918 err = rp.enforcer.E.SavePolicy()
919 if err != nil {
920 fail("Failed to update collaborator permissions.", err)
921 return
922 }
923
924 // clear aturi to when everything is successful
925 aturi = ""
926
927 rp.pages.HxRefresh(w)
928}
929
930func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
931 user := rp.oauth.GetUser(r)
932
933 noticeId := "operation-error"
934 f, err := rp.repoResolver.Resolve(r)
935 if err != nil {
936 log.Println("failed to get repo and knot", err)
937 return
938 }
939
940 // remove record from pds
941 xrpcClient, err := rp.oauth.AuthorizedClient(r)
942 if err != nil {
943 log.Println("failed to get authorized client", err)
944 return
945 }
946 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
947 Collection: tangled.RepoNSID,
948 Repo: user.Did,
949 Rkey: f.Rkey,
950 })
951 if err != nil {
952 log.Printf("failed to delete record: %s", err)
953 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
954 return
955 }
956 log.Println("removed repo record ", f.RepoAt().String())
957
958 client, err := rp.oauth.ServiceClient(
959 r,
960 oauth.WithService(f.Knot),
961 oauth.WithLxm(tangled.RepoDeleteNSID),
962 oauth.WithDev(rp.config.Core.Dev),
963 )
964 if err != nil {
965 log.Println("failed to connect to knot server:", err)
966 return
967 }
968
969 err = tangled.RepoDelete(
970 r.Context(),
971 client,
972 &tangled.RepoDelete_Input{
973 Did: f.OwnerDid(),
974 Name: f.Name,
975 Rkey: f.Rkey,
976 },
977 )
978 if err := xrpcclient.HandleXrpcErr(err); err != nil {
979 rp.pages.Notice(w, noticeId, err.Error())
980 return
981 }
982 log.Println("deleted repo from knot")
983
984 tx, err := rp.db.BeginTx(r.Context(), nil)
985 if err != nil {
986 log.Println("failed to start tx")
987 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
988 return
989 }
990 defer func() {
991 tx.Rollback()
992 err = rp.enforcer.E.LoadPolicy()
993 if err != nil {
994 log.Println("failed to rollback policies")
995 }
996 }()
997
998 // remove collaborator RBAC
999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1000 if err != nil {
1001 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1002 return
1003 }
1004 for _, c := range repoCollaborators {
1005 did := c[0]
1006 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1007 }
1008 log.Println("removed collaborators")
1009
1010 // remove repo RBAC
1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1012 if err != nil {
1013 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1014 return
1015 }
1016
1017 // remove repo from db
1018 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1019 if err != nil {
1020 rp.pages.Notice(w, noticeId, "Failed to update appview")
1021 return
1022 }
1023 log.Println("removed repo from db")
1024
1025 err = tx.Commit()
1026 if err != nil {
1027 log.Println("failed to commit changes", err)
1028 http.Error(w, err.Error(), http.StatusInternalServerError)
1029 return
1030 }
1031
1032 err = rp.enforcer.E.SavePolicy()
1033 if err != nil {
1034 log.Println("failed to update ACLs", err)
1035 http.Error(w, err.Error(), http.StatusInternalServerError)
1036 return
1037 }
1038
1039 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1040}
1041
1042func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1043 f, err := rp.repoResolver.Resolve(r)
1044 if err != nil {
1045 log.Println("failed to get repo and knot", err)
1046 return
1047 }
1048
1049 noticeId := "operation-error"
1050 branch := r.FormValue("branch")
1051 if branch == "" {
1052 http.Error(w, "malformed form", http.StatusBadRequest)
1053 return
1054 }
1055
1056 client, err := rp.oauth.ServiceClient(
1057 r,
1058 oauth.WithService(f.Knot),
1059 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1060 oauth.WithDev(rp.config.Core.Dev),
1061 )
1062 if err != nil {
1063 log.Println("failed to connect to knot server:", err)
1064 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1065 return
1066 }
1067
1068 xe := tangled.RepoSetDefaultBranch(
1069 r.Context(),
1070 client,
1071 &tangled.RepoSetDefaultBranch_Input{
1072 Repo: f.RepoAt().String(),
1073 DefaultBranch: branch,
1074 },
1075 )
1076 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1077 log.Println("xrpc failed", "err", xe)
1078 rp.pages.Notice(w, noticeId, err.Error())
1079 return
1080 }
1081
1082 rp.pages.HxRefresh(w)
1083}
1084
1085func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1086 user := rp.oauth.GetUser(r)
1087 l := rp.logger.With("handler", "Secrets")
1088 l = l.With("handle", user.Handle)
1089 l = l.With("did", user.Did)
1090
1091 f, err := rp.repoResolver.Resolve(r)
1092 if err != nil {
1093 log.Println("failed to get repo and knot", err)
1094 return
1095 }
1096
1097 if f.Spindle == "" {
1098 log.Println("empty spindle cannot add/rm secret", err)
1099 return
1100 }
1101
1102 lxm := tangled.RepoAddSecretNSID
1103 if r.Method == http.MethodDelete {
1104 lxm = tangled.RepoRemoveSecretNSID
1105 }
1106
1107 spindleClient, err := rp.oauth.ServiceClient(
1108 r,
1109 oauth.WithService(f.Spindle),
1110 oauth.WithLxm(lxm),
1111 oauth.WithExp(60),
1112 oauth.WithDev(rp.config.Core.Dev),
1113 )
1114 if err != nil {
1115 log.Println("failed to create spindle client", err)
1116 return
1117 }
1118
1119 key := r.FormValue("key")
1120 if key == "" {
1121 w.WriteHeader(http.StatusBadRequest)
1122 return
1123 }
1124
1125 switch r.Method {
1126 case http.MethodPut:
1127 errorId := "add-secret-error"
1128
1129 value := r.FormValue("value")
1130 if value == "" {
1131 w.WriteHeader(http.StatusBadRequest)
1132 return
1133 }
1134
1135 err = tangled.RepoAddSecret(
1136 r.Context(),
1137 spindleClient,
1138 &tangled.RepoAddSecret_Input{
1139 Repo: f.RepoAt().String(),
1140 Key: key,
1141 Value: value,
1142 },
1143 )
1144 if err != nil {
1145 l.Error("Failed to add secret.", "err", err)
1146 rp.pages.Notice(w, errorId, "Failed to add secret.")
1147 return
1148 }
1149
1150 case http.MethodDelete:
1151 errorId := "operation-error"
1152
1153 err = tangled.RepoRemoveSecret(
1154 r.Context(),
1155 spindleClient,
1156 &tangled.RepoRemoveSecret_Input{
1157 Repo: f.RepoAt().String(),
1158 Key: key,
1159 },
1160 )
1161 if err != nil {
1162 l.Error("Failed to delete secret.", "err", err)
1163 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1164 return
1165 }
1166 }
1167
1168 rp.pages.HxRefresh(w)
1169}
1170
1171type tab = map[string]any
1172
1173var (
1174 // would be great to have ordered maps right about now
1175 settingsTabs []tab = []tab{
1176 {"Name": "general", "Icon": "sliders-horizontal"},
1177 {"Name": "access", "Icon": "users"},
1178 {"Name": "pipelines", "Icon": "layers-2"},
1179 }
1180)
1181
1182func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1183 tabVal := r.URL.Query().Get("tab")
1184 if tabVal == "" {
1185 tabVal = "general"
1186 }
1187
1188 switch tabVal {
1189 case "general":
1190 rp.generalSettings(w, r)
1191
1192 case "access":
1193 rp.accessSettings(w, r)
1194
1195 case "pipelines":
1196 rp.pipelineSettings(w, r)
1197 }
1198}
1199
1200func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1201 f, err := rp.repoResolver.Resolve(r)
1202 user := rp.oauth.GetUser(r)
1203
1204 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1205 if err != nil {
1206 log.Println("failed to create unsigned client", err)
1207 return
1208 }
1209
1210 result, err := us.Branches(f.OwnerDid(), f.Name)
1211 if err != nil {
1212 rp.pages.Error503(w)
1213 log.Println("failed to reach knotserver", err)
1214 return
1215 }
1216
1217 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1218 LoggedInUser: user,
1219 RepoInfo: f.RepoInfo(user),
1220 Branches: result.Branches,
1221 Tabs: settingsTabs,
1222 Tab: "general",
1223 })
1224}
1225
1226func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1227 f, err := rp.repoResolver.Resolve(r)
1228 user := rp.oauth.GetUser(r)
1229
1230 repoCollaborators, err := f.Collaborators(r.Context())
1231 if err != nil {
1232 log.Println("failed to get collaborators", err)
1233 }
1234
1235 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1236 LoggedInUser: user,
1237 RepoInfo: f.RepoInfo(user),
1238 Tabs: settingsTabs,
1239 Tab: "access",
1240 Collaborators: repoCollaborators,
1241 })
1242}
1243
1244func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1245 f, err := rp.repoResolver.Resolve(r)
1246 user := rp.oauth.GetUser(r)
1247
1248 // all spindles that the repo owner is a member of
1249 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1250 if err != nil {
1251 log.Println("failed to fetch spindles", err)
1252 return
1253 }
1254
1255 var secrets []*tangled.RepoListSecrets_Secret
1256 if f.Spindle != "" {
1257 if spindleClient, err := rp.oauth.ServiceClient(
1258 r,
1259 oauth.WithService(f.Spindle),
1260 oauth.WithLxm(tangled.RepoListSecretsNSID),
1261 oauth.WithExp(60),
1262 oauth.WithDev(rp.config.Core.Dev),
1263 ); err != nil {
1264 log.Println("failed to create spindle client", err)
1265 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1266 log.Println("failed to fetch secrets", err)
1267 } else {
1268 secrets = resp.Secrets
1269 }
1270 }
1271
1272 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1273 return strings.Compare(a.Key, b.Key)
1274 })
1275
1276 var dids []string
1277 for _, s := range secrets {
1278 dids = append(dids, s.CreatedBy)
1279 }
1280 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1281
1282 // convert to a more manageable form
1283 var niceSecret []map[string]any
1284 for id, s := range secrets {
1285 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1286 niceSecret = append(niceSecret, map[string]any{
1287 "Id": id,
1288 "Key": s.Key,
1289 "CreatedAt": when,
1290 "CreatedBy": resolvedIdents[id].Handle.String(),
1291 })
1292 }
1293
1294 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1295 LoggedInUser: user,
1296 RepoInfo: f.RepoInfo(user),
1297 Tabs: settingsTabs,
1298 Tab: "pipelines",
1299 Spindles: spindles,
1300 CurrentSpindle: f.Spindle,
1301 Secrets: niceSecret,
1302 })
1303}
1304
1305func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1306 ref := chi.URLParam(r, "ref")
1307
1308 user := rp.oauth.GetUser(r)
1309 f, err := rp.repoResolver.Resolve(r)
1310 if err != nil {
1311 log.Printf("failed to resolve source repo: %v", err)
1312 return
1313 }
1314
1315 switch r.Method {
1316 case http.MethodPost:
1317 client, err := rp.oauth.ServiceClient(
1318 r,
1319 oauth.WithService(f.Knot),
1320 oauth.WithLxm(tangled.RepoForkSyncNSID),
1321 oauth.WithDev(rp.config.Core.Dev),
1322 )
1323 if err != nil {
1324 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1325 return
1326 }
1327
1328 repoInfo := f.RepoInfo(user)
1329 if repoInfo.Source == nil {
1330 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1331 return
1332 }
1333
1334 err = tangled.RepoForkSync(
1335 r.Context(),
1336 client,
1337 &tangled.RepoForkSync_Input{
1338 Did: user.Did,
1339 Name: f.Name,
1340 Source: repoInfo.Source.RepoAt().String(),
1341 Branch: ref,
1342 },
1343 )
1344 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1345 rp.pages.Notice(w, "repo", err.Error())
1346 return
1347 }
1348
1349 rp.pages.HxRefresh(w)
1350 return
1351 }
1352}
1353
1354func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1355 user := rp.oauth.GetUser(r)
1356 f, err := rp.repoResolver.Resolve(r)
1357 if err != nil {
1358 log.Printf("failed to resolve source repo: %v", err)
1359 return
1360 }
1361
1362 switch r.Method {
1363 case http.MethodGet:
1364 user := rp.oauth.GetUser(r)
1365 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1366 if err != nil {
1367 rp.pages.Notice(w, "repo", "Invalid user account.")
1368 return
1369 }
1370
1371 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1372 LoggedInUser: user,
1373 Knots: knots,
1374 RepoInfo: f.RepoInfo(user),
1375 })
1376
1377 case http.MethodPost:
1378 l := rp.logger.With("handler", "ForkRepo")
1379
1380 targetKnot := r.FormValue("knot")
1381 if targetKnot == "" {
1382 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1383 return
1384 }
1385 l = l.With("targetKnot", targetKnot)
1386
1387 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1388 if err != nil || !ok {
1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1390 return
1391 }
1392
1393 // choose a name for a fork
1394 forkName := f.Name
1395 // this check is *only* to see if the forked repo name already exists
1396 // in the user's account.
1397 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1398 if err != nil {
1399 if errors.Is(err, sql.ErrNoRows) {
1400 // no existing repo with this name found, we can use the name as is
1401 } else {
1402 log.Println("error fetching existing repo from db", err)
1403 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1404 return
1405 }
1406 } else if existingRepo != nil {
1407 // repo with this name already exists, append random string
1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1409 }
1410 l = l.With("forkName", forkName)
1411
1412 uri := "https"
1413 if rp.config.Core.Dev {
1414 uri = "http"
1415 }
1416
1417 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1418 l = l.With("cloneUrl", forkSourceUrl)
1419
1420 sourceAt := f.RepoAt().String()
1421
1422 // create an atproto record for this fork
1423 rkey := tid.TID()
1424 repo := &db.Repo{
1425 Did: user.Did,
1426 Name: forkName,
1427 Knot: targetKnot,
1428 Rkey: rkey,
1429 Source: sourceAt,
1430 }
1431
1432 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1433 if err != nil {
1434 l.Error("failed to create xrpcclient", "err", err)
1435 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1436 return
1437 }
1438
1439 createdAt := time.Now().Format(time.RFC3339)
1440 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1441 Collection: tangled.RepoNSID,
1442 Repo: user.Did,
1443 Rkey: rkey,
1444 Record: &lexutil.LexiconTypeDecoder{
1445 Val: &tangled.Repo{
1446 Knot: repo.Knot,
1447 Name: repo.Name,
1448 CreatedAt: createdAt,
1449 Owner: user.Did,
1450 Source: &sourceAt,
1451 }},
1452 })
1453 if err != nil {
1454 l.Error("failed to write to PDS", "err", err)
1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1456 return
1457 }
1458
1459 aturi := atresp.Uri
1460 l = l.With("aturi", aturi)
1461 l.Info("wrote to PDS")
1462
1463 tx, err := rp.db.BeginTx(r.Context(), nil)
1464 if err != nil {
1465 l.Info("txn failed", "err", err)
1466 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1467 return
1468 }
1469
1470 // The rollback function reverts a few things on failure:
1471 // - the pending txn
1472 // - the ACLs
1473 // - the atproto record created
1474 rollback := func() {
1475 err1 := tx.Rollback()
1476 err2 := rp.enforcer.E.LoadPolicy()
1477 err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1478
1479 // ignore txn complete errors, this is okay
1480 if errors.Is(err1, sql.ErrTxDone) {
1481 err1 = nil
1482 }
1483
1484 if errs := errors.Join(err1, err2, err3); errs != nil {
1485 l.Error("failed to rollback changes", "errs", errs)
1486 return
1487 }
1488 }
1489 defer rollback()
1490
1491 client, err := rp.oauth.ServiceClient(
1492 r,
1493 oauth.WithService(targetKnot),
1494 oauth.WithLxm(tangled.RepoCreateNSID),
1495 oauth.WithDev(rp.config.Core.Dev),
1496 )
1497 if err != nil {
1498 l.Error("could not create service client", "err", err)
1499 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1500 return
1501 }
1502
1503 err = tangled.RepoCreate(
1504 r.Context(),
1505 client,
1506 &tangled.RepoCreate_Input{
1507 Rkey: rkey,
1508 Source: &forkSourceUrl,
1509 },
1510 )
1511 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1512 rp.pages.Notice(w, "repo", err.Error())
1513 return
1514 }
1515
1516 err = db.AddRepo(tx, repo)
1517 if err != nil {
1518 log.Println(err)
1519 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1520 return
1521 }
1522
1523 // acls
1524 p, _ := securejoin.SecureJoin(user.Did, forkName)
1525 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1526 if err != nil {
1527 log.Println(err)
1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1529 return
1530 }
1531
1532 err = tx.Commit()
1533 if err != nil {
1534 log.Println("failed to commit changes", err)
1535 http.Error(w, err.Error(), http.StatusInternalServerError)
1536 return
1537 }
1538
1539 err = rp.enforcer.E.SavePolicy()
1540 if err != nil {
1541 log.Println("failed to update ACLs", err)
1542 http.Error(w, err.Error(), http.StatusInternalServerError)
1543 return
1544 }
1545
1546 // reset the ATURI because the transaction completed successfully
1547 aturi = ""
1548
1549 rp.notifier.NewRepo(r.Context(), repo)
1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1551 }
1552}
1553
1554// this is used to rollback changes made to the PDS
1555//
1556// it is a no-op if the provided ATURI is empty
1557func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1558 if aturi == "" {
1559 return nil
1560 }
1561
1562 parsed := syntax.ATURI(aturi)
1563
1564 collection := parsed.Collection().String()
1565 repo := parsed.Authority().String()
1566 rkey := parsed.RecordKey().String()
1567
1568 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1569 Collection: collection,
1570 Repo: repo,
1571 Rkey: rkey,
1572 })
1573 return err
1574}
1575
1576func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1577 user := rp.oauth.GetUser(r)
1578 f, err := rp.repoResolver.Resolve(r)
1579 if err != nil {
1580 log.Println("failed to get repo and knot", err)
1581 return
1582 }
1583
1584 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1585 if err != nil {
1586 log.Printf("failed to create unsigned client for %s", f.Knot)
1587 rp.pages.Error503(w)
1588 return
1589 }
1590
1591 result, err := us.Branches(f.OwnerDid(), f.Name)
1592 if err != nil {
1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1594 log.Println("failed to reach knotserver", err)
1595 return
1596 }
1597 branches := result.Branches
1598
1599 sortBranches(branches)
1600
1601 var defaultBranch string
1602 for _, b := range branches {
1603 if b.IsDefault {
1604 defaultBranch = b.Name
1605 }
1606 }
1607
1608 base := defaultBranch
1609 head := defaultBranch
1610
1611 params := r.URL.Query()
1612 queryBase := params.Get("base")
1613 queryHead := params.Get("head")
1614 if queryBase != "" {
1615 base = queryBase
1616 }
1617 if queryHead != "" {
1618 head = queryHead
1619 }
1620
1621 tags, err := us.Tags(f.OwnerDid(), f.Name)
1622 if err != nil {
1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1624 log.Println("failed to reach knotserver", err)
1625 return
1626 }
1627
1628 repoinfo := f.RepoInfo(user)
1629
1630 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1631 LoggedInUser: user,
1632 RepoInfo: repoinfo,
1633 Branches: branches,
1634 Tags: tags.Tags,
1635 Base: base,
1636 Head: head,
1637 })
1638}
1639
1640func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1641 user := rp.oauth.GetUser(r)
1642 f, err := rp.repoResolver.Resolve(r)
1643 if err != nil {
1644 log.Println("failed to get repo and knot", err)
1645 return
1646 }
1647
1648 var diffOpts types.DiffOpts
1649 if d := r.URL.Query().Get("diff"); d == "split" {
1650 diffOpts.Split = true
1651 }
1652
1653 // if user is navigating to one of
1654 // /compare/{base}/{head}
1655 // /compare/{base}...{head}
1656 base := chi.URLParam(r, "base")
1657 head := chi.URLParam(r, "head")
1658 if base == "" && head == "" {
1659 rest := chi.URLParam(r, "*") // master...feature/xyz
1660 parts := strings.SplitN(rest, "...", 2)
1661 if len(parts) == 2 {
1662 base = parts[0]
1663 head = parts[1]
1664 }
1665 }
1666
1667 base, _ = url.PathUnescape(base)
1668 head, _ = url.PathUnescape(head)
1669
1670 if base == "" || head == "" {
1671 log.Printf("invalid comparison")
1672 rp.pages.Error404(w)
1673 return
1674 }
1675
1676 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1677 if err != nil {
1678 log.Printf("failed to create unsigned client for %s", f.Knot)
1679 rp.pages.Error503(w)
1680 return
1681 }
1682
1683 branches, err := us.Branches(f.OwnerDid(), f.Name)
1684 if err != nil {
1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1686 log.Println("failed to reach knotserver", err)
1687 return
1688 }
1689
1690 tags, err := us.Tags(f.OwnerDid(), f.Name)
1691 if err != nil {
1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1693 log.Println("failed to reach knotserver", err)
1694 return
1695 }
1696
1697 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1698 if err != nil {
1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1700 log.Println("failed to compare", err)
1701 return
1702 }
1703 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1704
1705 repoinfo := f.RepoInfo(user)
1706
1707 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1708 LoggedInUser: user,
1709 RepoInfo: repoinfo,
1710 Branches: branches.Branches,
1711 Tags: tags.Tags,
1712 Base: base,
1713 Head: head,
1714 Diff: &diff,
1715 DiffOpts: diffOpts,
1716 })
1717
1718}