this repo has no description
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "path/filepath"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19
20 comatproto "github.com/bluesky-social/indigo/api/atproto"
21 lexutil "github.com/bluesky-social/indigo/lex/util"
22 "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 f, err := rp.repoResolver.Resolve(r)
934 if err != nil {
935 log.Println("failed to get repo and knot", err)
936 return
937 }
938
939 // remove record from pds
940 xrpcClient, err := rp.oauth.AuthorizedClient(r)
941 if err != nil {
942 log.Println("failed to get authorized client", err)
943 return
944 }
945 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
946 Collection: tangled.RepoNSID,
947 Repo: user.Did,
948 Rkey: f.Rkey,
949 })
950 if err != nil {
951 log.Printf("failed to delete record: %s", err)
952 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
953 return
954 }
955 log.Println("removed repo record ", f.RepoAt().String())
956
957 client, err := rp.oauth.ServiceClient(
958 r,
959 oauth.WithService(f.Knot),
960 oauth.WithLxm(tangled.RepoDeleteNSID),
961 oauth.WithDev(rp.config.Core.Dev),
962 )
963 if err != nil {
964 log.Println("failed to connect to knot server:", err)
965 return
966 }
967
968 err = tangled.RepoDelete(
969 r.Context(),
970 client,
971 &tangled.RepoDelete_Input{
972 Did: f.OwnerDid(),
973 Name: f.Name,
974 },
975 )
976 if err != nil {
977 xe, parseErr := xrpcerr.Unmarshal(err.Error())
978 if parseErr != nil {
979 log.Printf("failed to delete repo from knot %s: %s", f.Knot, err)
980 } else {
981 log.Printf("failed to delete repo from knot %s: %s", f.Knot, xe.Error())
982 }
983 // Continue anyway since we want to clean up local state
984 } else {
985 log.Println("removed repo from knot ", f.Knot)
986 }
987
988 tx, err := rp.db.BeginTx(r.Context(), nil)
989 if err != nil {
990 log.Println("failed to start tx")
991 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
992 return
993 }
994 defer func() {
995 tx.Rollback()
996 err = rp.enforcer.E.LoadPolicy()
997 if err != nil {
998 log.Println("failed to rollback policies")
999 }
1000 }()
1001
1002 // remove collaborator RBAC
1003 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1004 if err != nil {
1005 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1006 return
1007 }
1008 for _, c := range repoCollaborators {
1009 did := c[0]
1010 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1011 }
1012 log.Println("removed collaborators")
1013
1014 // remove repo RBAC
1015 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1016 if err != nil {
1017 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1018 return
1019 }
1020
1021 // remove repo from db
1022 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1023 if err != nil {
1024 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1025 return
1026 }
1027 log.Println("removed repo from db")
1028
1029 err = tx.Commit()
1030 if err != nil {
1031 log.Println("failed to commit changes", err)
1032 http.Error(w, err.Error(), http.StatusInternalServerError)
1033 return
1034 }
1035
1036 err = rp.enforcer.E.SavePolicy()
1037 if err != nil {
1038 log.Println("failed to update ACLs", err)
1039 http.Error(w, err.Error(), http.StatusInternalServerError)
1040 return
1041 }
1042
1043 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1044}
1045
1046func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1047 f, err := rp.repoResolver.Resolve(r)
1048 if err != nil {
1049 log.Println("failed to get repo and knot", err)
1050 return
1051 }
1052
1053 branch := r.FormValue("branch")
1054 if branch == "" {
1055 http.Error(w, "malformed form", http.StatusBadRequest)
1056 return
1057 }
1058
1059 client, err := rp.oauth.ServiceClient(
1060 r,
1061 oauth.WithService(f.Knot),
1062 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1063 oauth.WithDev(rp.config.Core.Dev),
1064 )
1065 if err != nil {
1066 log.Println("failed to connect to knot server:", err)
1067 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1068 return
1069 }
1070
1071 xe := tangled.RepoSetDefaultBranch(
1072 r.Context(),
1073 client,
1074 &tangled.RepoSetDefaultBranch_Input{
1075 Repo: f.RepoAt().String(),
1076 DefaultBranch: branch,
1077 },
1078 )
1079 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1080 log.Println("xrpc failed", "err", xe)
1081 rp.pages.Notice(w, noticeId, err.Error())
1082 return
1083 }
1084
1085 w.Write(fmt.Append(nil, "default branch set to: ", branch))
1086}
1087
1088func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1089 user := rp.oauth.GetUser(r)
1090 l := rp.logger.With("handler", "Secrets")
1091 l = l.With("handle", user.Handle)
1092 l = l.With("did", user.Did)
1093
1094 f, err := rp.repoResolver.Resolve(r)
1095 if err != nil {
1096 log.Println("failed to get repo and knot", err)
1097 return
1098 }
1099
1100 if f.Spindle == "" {
1101 log.Println("empty spindle cannot add/rm secret", err)
1102 return
1103 }
1104
1105 lxm := tangled.RepoAddSecretNSID
1106 if r.Method == http.MethodDelete {
1107 lxm = tangled.RepoRemoveSecretNSID
1108 }
1109
1110 spindleClient, err := rp.oauth.ServiceClient(
1111 r,
1112 oauth.WithService(f.Spindle),
1113 oauth.WithLxm(lxm),
1114 oauth.WithExp(60),
1115 oauth.WithDev(rp.config.Core.Dev),
1116 )
1117 if err != nil {
1118 log.Println("failed to create spindle client", err)
1119 return
1120 }
1121
1122 key := r.FormValue("key")
1123 if key == "" {
1124 w.WriteHeader(http.StatusBadRequest)
1125 return
1126 }
1127
1128 switch r.Method {
1129 case http.MethodPut:
1130 errorId := "add-secret-error"
1131
1132 value := r.FormValue("value")
1133 if value == "" {
1134 w.WriteHeader(http.StatusBadRequest)
1135 return
1136 }
1137
1138 err = tangled.RepoAddSecret(
1139 r.Context(),
1140 spindleClient,
1141 &tangled.RepoAddSecret_Input{
1142 Repo: f.RepoAt().String(),
1143 Key: key,
1144 Value: value,
1145 },
1146 )
1147 if err != nil {
1148 l.Error("Failed to add secret.", "err", err)
1149 rp.pages.Notice(w, errorId, "Failed to add secret.")
1150 return
1151 }
1152
1153 case http.MethodDelete:
1154 errorId := "operation-error"
1155
1156 err = tangled.RepoRemoveSecret(
1157 r.Context(),
1158 spindleClient,
1159 &tangled.RepoRemoveSecret_Input{
1160 Repo: f.RepoAt().String(),
1161 Key: key,
1162 },
1163 )
1164 if err != nil {
1165 l.Error("Failed to delete secret.", "err", err)
1166 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1167 return
1168 }
1169 }
1170
1171 rp.pages.HxRefresh(w)
1172}
1173
1174type tab = map[string]any
1175
1176var (
1177 // would be great to have ordered maps right about now
1178 settingsTabs []tab = []tab{
1179 {"Name": "general", "Icon": "sliders-horizontal"},
1180 {"Name": "access", "Icon": "users"},
1181 {"Name": "pipelines", "Icon": "layers-2"},
1182 }
1183)
1184
1185func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1186 tabVal := r.URL.Query().Get("tab")
1187 if tabVal == "" {
1188 tabVal = "general"
1189 }
1190
1191 switch tabVal {
1192 case "general":
1193 rp.generalSettings(w, r)
1194
1195 case "access":
1196 rp.accessSettings(w, r)
1197
1198 case "pipelines":
1199 rp.pipelineSettings(w, r)
1200 }
1201}
1202
1203func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1204 f, err := rp.repoResolver.Resolve(r)
1205 user := rp.oauth.GetUser(r)
1206
1207 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1208 if err != nil {
1209 log.Println("failed to create unsigned client", err)
1210 return
1211 }
1212
1213 result, err := us.Branches(f.OwnerDid(), f.Name)
1214 if err != nil {
1215 rp.pages.Error503(w)
1216 log.Println("failed to reach knotserver", err)
1217 return
1218 }
1219
1220 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1221 LoggedInUser: user,
1222 RepoInfo: f.RepoInfo(user),
1223 Branches: result.Branches,
1224 Tabs: settingsTabs,
1225 Tab: "general",
1226 })
1227}
1228
1229func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1230 f, err := rp.repoResolver.Resolve(r)
1231 user := rp.oauth.GetUser(r)
1232
1233 repoCollaborators, err := f.Collaborators(r.Context())
1234 if err != nil {
1235 log.Println("failed to get collaborators", err)
1236 }
1237
1238 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1239 LoggedInUser: user,
1240 RepoInfo: f.RepoInfo(user),
1241 Tabs: settingsTabs,
1242 Tab: "access",
1243 Collaborators: repoCollaborators,
1244 })
1245}
1246
1247func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1248 f, err := rp.repoResolver.Resolve(r)
1249 user := rp.oauth.GetUser(r)
1250
1251 // all spindles that the repo owner is a member of
1252 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1253 if err != nil {
1254 log.Println("failed to fetch spindles", err)
1255 return
1256 }
1257
1258 var secrets []*tangled.RepoListSecrets_Secret
1259 if f.Spindle != "" {
1260 if spindleClient, err := rp.oauth.ServiceClient(
1261 r,
1262 oauth.WithService(f.Spindle),
1263 oauth.WithLxm(tangled.RepoListSecretsNSID),
1264 oauth.WithExp(60),
1265 oauth.WithDev(rp.config.Core.Dev),
1266 ); err != nil {
1267 log.Println("failed to create spindle client", err)
1268 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1269 log.Println("failed to fetch secrets", err)
1270 } else {
1271 secrets = resp.Secrets
1272 }
1273 }
1274
1275 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1276 return strings.Compare(a.Key, b.Key)
1277 })
1278
1279 var dids []string
1280 for _, s := range secrets {
1281 dids = append(dids, s.CreatedBy)
1282 }
1283 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1284
1285 // convert to a more manageable form
1286 var niceSecret []map[string]any
1287 for id, s := range secrets {
1288 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1289 niceSecret = append(niceSecret, map[string]any{
1290 "Id": id,
1291 "Key": s.Key,
1292 "CreatedAt": when,
1293 "CreatedBy": resolvedIdents[id].Handle.String(),
1294 })
1295 }
1296
1297 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1298 LoggedInUser: user,
1299 RepoInfo: f.RepoInfo(user),
1300 Tabs: settingsTabs,
1301 Tab: "pipelines",
1302 Spindles: spindles,
1303 CurrentSpindle: f.Spindle,
1304 Secrets: niceSecret,
1305 })
1306}
1307
1308func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1309 ref := chi.URLParam(r, "ref")
1310
1311 user := rp.oauth.GetUser(r)
1312 f, err := rp.repoResolver.Resolve(r)
1313 if err != nil {
1314 log.Printf("failed to resolve source repo: %v", err)
1315 return
1316 }
1317
1318 switch r.Method {
1319 case http.MethodPost:
1320 client, err := rp.oauth.ServiceClient(
1321 r,
1322 oauth.WithService(f.Knot),
1323 oauth.WithLxm(tangled.RepoForkSyncNSID),
1324 oauth.WithDev(rp.config.Core.Dev),
1325 )
1326 if err != nil {
1327 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1328 return
1329 }
1330
1331 repoInfo := f.RepoInfo(user)
1332 if repoInfo.Source == nil {
1333 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1334 return
1335 }
1336
1337 err = tangled.RepoForkSync(
1338 r.Context(),
1339 client,
1340 &tangled.RepoForkSync_Input{
1341 Did: user.Did,
1342 Name: f.Name,
1343 Source: repoInfo.Source.RepoAt().String(),
1344 Branch: ref,
1345 },
1346 )
1347 if err != nil {
1348 xe, parseErr := xrpcerr.Unmarshal(err.Error())
1349 if parseErr != nil {
1350 log.Printf("failed to sync repository fork: %s", err)
1351 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1352 } else {
1353 log.Printf("failed to sync repository fork: %s", xe.Error())
1354 rp.pages.Notice(w, "repo", fmt.Sprintf("Failed to sync repository fork: %s", xe.Message))
1355 }
1356 return
1357 }
1358
1359 rp.pages.HxRefresh(w)
1360 return
1361 }
1362}
1363
1364func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1365 user := rp.oauth.GetUser(r)
1366 f, err := rp.repoResolver.Resolve(r)
1367 if err != nil {
1368 log.Printf("failed to resolve source repo: %v", err)
1369 return
1370 }
1371
1372 switch r.Method {
1373 case http.MethodGet:
1374 user := rp.oauth.GetUser(r)
1375 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1376 if err != nil {
1377 rp.pages.Notice(w, "repo", "Invalid user account.")
1378 return
1379 }
1380
1381 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1382 LoggedInUser: user,
1383 Knots: knots,
1384 RepoInfo: f.RepoInfo(user),
1385 })
1386
1387 case http.MethodPost:
1388 l := rp.logger.With("handler", "ForkRepo")
1389
1390 targetKnot := r.FormValue("knot")
1391 if targetKnot == "" {
1392 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1393 return
1394 }
1395 l = l.With("targetKnot", targetKnot)
1396
1397 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1398 if err != nil || !ok {
1399 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1400 return
1401 }
1402
1403 // choose a name for a fork
1404 forkName := f.Name
1405 // this check is *only* to see if the forked repo name already exists
1406 // in the user's account.
1407 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1408 if err != nil {
1409 if errors.Is(err, sql.ErrNoRows) {
1410 // no existing repo with this name found, we can use the name as is
1411 } else {
1412 log.Println("error fetching existing repo from db", err)
1413 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1414 return
1415 }
1416 } else if existingRepo != nil {
1417 // repo with this name already exists, append random string
1418 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1419 }
1420 l = l.With("forkName", forkName)
1421
1422 uri := "https"
1423 if rp.config.Core.Dev {
1424 uri = "http"
1425 }
1426
1427 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1428 l = l.With("cloneUrl", forkSourceUrl)
1429
1430 sourceAt := f.RepoAt().String()
1431
1432 // create an atproto record for this fork
1433 rkey := tid.TID()
1434 repo := &db.Repo{
1435 Did: user.Did,
1436 Name: forkName,
1437 Knot: targetKnot,
1438 Rkey: rkey,
1439 Source: sourceAt,
1440 }
1441
1442 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1443 if err != nil {
1444 l.Error("failed to create xrpcclient", "err", err)
1445 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1446 return
1447 }
1448
1449 createdAt := time.Now().Format(time.RFC3339)
1450 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1451 Collection: tangled.RepoNSID,
1452 Repo: user.Did,
1453 Rkey: rkey,
1454 Record: &lexutil.LexiconTypeDecoder{
1455 Val: &tangled.Repo{
1456 Knot: repo.Knot,
1457 Name: repo.Name,
1458 CreatedAt: createdAt,
1459 Owner: user.Did,
1460 Source: &sourceAt,
1461 }},
1462 })
1463 if err != nil {
1464 l.Error("failed to write to PDS", "err", err)
1465 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1466 return
1467 }
1468
1469 aturi := atresp.Uri
1470 l = l.With("aturi", aturi)
1471 l.Info("wrote to PDS")
1472
1473 tx, err := rp.db.BeginTx(r.Context(), nil)
1474 if err != nil {
1475 l.Info("txn failed", "err", err)
1476 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1477 return
1478 }
1479
1480 // The rollback function reverts a few things on failure:
1481 // - the pending txn
1482 // - the ACLs
1483 // - the atproto record created
1484 rollback := func() {
1485 err1 := tx.Rollback()
1486 err2 := rp.enforcer.E.LoadPolicy()
1487 err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1488
1489 // ignore txn complete errors, this is okay
1490 if errors.Is(err1, sql.ErrTxDone) {
1491 err1 = nil
1492 }
1493
1494 if errs := errors.Join(err1, err2, err3); errs != nil {
1495 l.Error("failed to rollback changes", "errs", errs)
1496 return
1497 }
1498 }
1499 defer rollback()
1500
1501 client, err := rp.oauth.ServiceClient(
1502 r,
1503 oauth.WithService(targetKnot),
1504 oauth.WithLxm(tangled.RepoCreateNSID),
1505 oauth.WithDev(rp.config.Core.Dev),
1506 )
1507 if err != nil {
1508 l.Error("could not create service client", "err", err)
1509 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1510 return
1511 }
1512
1513 err = tangled.RepoCreate(
1514 r.Context(),
1515 client,
1516 &tangled.RepoCreate_Input{
1517 Rkey: rkey,
1518 Source: &forkSourceUrl,
1519 },
1520 )
1521 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1522 rp.pages.Notice(w, "repo", err.Error())
1523 return
1524 }
1525
1526 err = db.AddRepo(tx, repo)
1527 if err != nil {
1528 log.Println(err)
1529 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1530 return
1531 }
1532
1533 // acls
1534 p, _ := securejoin.SecureJoin(user.Did, forkName)
1535 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1536 if err != nil {
1537 log.Println(err)
1538 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1539 return
1540 }
1541
1542 err = tx.Commit()
1543 if err != nil {
1544 log.Println("failed to commit changes", err)
1545 http.Error(w, err.Error(), http.StatusInternalServerError)
1546 return
1547 }
1548
1549 err = rp.enforcer.E.SavePolicy()
1550 if err != nil {
1551 log.Println("failed to update ACLs", err)
1552 http.Error(w, err.Error(), http.StatusInternalServerError)
1553 return
1554 }
1555
1556 // reset the ATURI because the transaction completed successfully
1557 aturi = ""
1558
1559 rp.notifier.NewRepo(r.Context(), repo)
1560 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1561 }
1562}
1563
1564// this is used to rollback changes made to the PDS
1565//
1566// it is a no-op if the provided ATURI is empty
1567func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1568 if aturi == "" {
1569 return nil
1570 }
1571
1572 parsed := syntax.ATURI(aturi)
1573
1574 collection := parsed.Collection().String()
1575 repo := parsed.Authority().String()
1576 rkey := parsed.RecordKey().String()
1577
1578 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1579 Collection: collection,
1580 Repo: repo,
1581 Rkey: rkey,
1582 })
1583 return err
1584}
1585
1586func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1587 user := rp.oauth.GetUser(r)
1588 f, err := rp.repoResolver.Resolve(r)
1589 if err != nil {
1590 log.Println("failed to get repo and knot", err)
1591 return
1592 }
1593
1594 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1595 if err != nil {
1596 log.Printf("failed to create unsigned client for %s", f.Knot)
1597 rp.pages.Error503(w)
1598 return
1599 }
1600
1601 result, err := us.Branches(f.OwnerDid(), f.Name)
1602 if err != nil {
1603 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1604 log.Println("failed to reach knotserver", err)
1605 return
1606 }
1607 branches := result.Branches
1608
1609 sortBranches(branches)
1610
1611 var defaultBranch string
1612 for _, b := range branches {
1613 if b.IsDefault {
1614 defaultBranch = b.Name
1615 }
1616 }
1617
1618 base := defaultBranch
1619 head := defaultBranch
1620
1621 params := r.URL.Query()
1622 queryBase := params.Get("base")
1623 queryHead := params.Get("head")
1624 if queryBase != "" {
1625 base = queryBase
1626 }
1627 if queryHead != "" {
1628 head = queryHead
1629 }
1630
1631 tags, err := us.Tags(f.OwnerDid(), f.Name)
1632 if err != nil {
1633 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1634 log.Println("failed to reach knotserver", err)
1635 return
1636 }
1637
1638 repoinfo := f.RepoInfo(user)
1639
1640 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1641 LoggedInUser: user,
1642 RepoInfo: repoinfo,
1643 Branches: branches,
1644 Tags: tags.Tags,
1645 Base: base,
1646 Head: head,
1647 })
1648}
1649
1650func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1651 user := rp.oauth.GetUser(r)
1652 f, err := rp.repoResolver.Resolve(r)
1653 if err != nil {
1654 log.Println("failed to get repo and knot", err)
1655 return
1656 }
1657
1658 var diffOpts types.DiffOpts
1659 if d := r.URL.Query().Get("diff"); d == "split" {
1660 diffOpts.Split = true
1661 }
1662
1663 // if user is navigating to one of
1664 // /compare/{base}/{head}
1665 // /compare/{base}...{head}
1666 base := chi.URLParam(r, "base")
1667 head := chi.URLParam(r, "head")
1668 if base == "" && head == "" {
1669 rest := chi.URLParam(r, "*") // master...feature/xyz
1670 parts := strings.SplitN(rest, "...", 2)
1671 if len(parts) == 2 {
1672 base = parts[0]
1673 head = parts[1]
1674 }
1675 }
1676
1677 base, _ = url.PathUnescape(base)
1678 head, _ = url.PathUnescape(head)
1679
1680 if base == "" || head == "" {
1681 log.Printf("invalid comparison")
1682 rp.pages.Error404(w)
1683 return
1684 }
1685
1686 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1687 if err != nil {
1688 log.Printf("failed to create unsigned client for %s", f.Knot)
1689 rp.pages.Error503(w)
1690 return
1691 }
1692
1693 branches, err := us.Branches(f.OwnerDid(), f.Name)
1694 if err != nil {
1695 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1696 log.Println("failed to reach knotserver", err)
1697 return
1698 }
1699
1700 tags, err := us.Tags(f.OwnerDid(), f.Name)
1701 if err != nil {
1702 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1703 log.Println("failed to reach knotserver", err)
1704 return
1705 }
1706
1707 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1708 if err != nil {
1709 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1710 log.Println("failed to compare", err)
1711 return
1712 }
1713 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1714
1715 repoinfo := f.RepoInfo(user)
1716
1717 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1718 LoggedInUser: user,
1719 RepoInfo: repoinfo,
1720 Branches: branches.Branches,
1721 Tags: tags.Tags,
1722 Base: base,
1723 Head: head,
1724 Diff: &diff,
1725 DiffOpts: diffOpts,
1726 })
1727
1728}