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 // Safely serve content based on type
689 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
690 // Serve all textual content as text/plain for security
691 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
692 w.Write(body)
693 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
694 // Serve images and videos with their original content type
695 w.Header().Set("Content-Type", contentType)
696 w.Write(body)
697 } else {
698 // Block potentially dangerous content types
699 w.WriteHeader(http.StatusUnsupportedMediaType)
700 w.Write([]byte("unsupported content type"))
701 return
702 }
703}
704
705// isTextualMimeType returns true if the MIME type represents textual content
706// that should be served as text/plain
707func isTextualMimeType(mimeType string) bool {
708 textualTypes := []string{
709 "application/json",
710 "application/xml",
711 "application/yaml",
712 "application/x-yaml",
713 "application/toml",
714 "application/javascript",
715 "application/ecmascript",
716 "message/",
717 }
718
719 for _, t := range textualTypes {
720 if mimeType == t {
721 return true
722 }
723 }
724 return false
725}
726
727// modify the spindle configured for this repo
728func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
729 user := rp.oauth.GetUser(r)
730 l := rp.logger.With("handler", "EditSpindle")
731 l = l.With("did", user.Did)
732 l = l.With("handle", user.Handle)
733
734 errorId := "operation-error"
735 fail := func(msg string, err error) {
736 l.Error(msg, "err", err)
737 rp.pages.Notice(w, errorId, msg)
738 }
739
740 f, err := rp.repoResolver.Resolve(r)
741 if err != nil {
742 fail("Failed to resolve repo. Try again later", err)
743 return
744 }
745
746 repoAt := f.RepoAt()
747 rkey := repoAt.RecordKey().String()
748 if rkey == "" {
749 fail("Failed to resolve repo. Try again later", err)
750 return
751 }
752
753 newSpindle := r.FormValue("spindle")
754 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
755 client, err := rp.oauth.AuthorizedClient(r)
756 if err != nil {
757 fail("Failed to authorize. Try again later.", err)
758 return
759 }
760
761 if !removingSpindle {
762 // ensure that this is a valid spindle for this user
763 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
764 if err != nil {
765 fail("Failed to find spindles. Try again later.", err)
766 return
767 }
768
769 if !slices.Contains(validSpindles, newSpindle) {
770 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
771 return
772 }
773 }
774
775 spindlePtr := &newSpindle
776 if removingSpindle {
777 spindlePtr = nil
778 }
779
780 // optimistic update
781 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
782 if err != nil {
783 fail("Failed to update spindle. Try again later.", err)
784 return
785 }
786
787 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
788 if err != nil {
789 fail("Failed to update spindle, no record found on PDS.", err)
790 return
791 }
792 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
793 Collection: tangled.RepoNSID,
794 Repo: user.Did,
795 Rkey: rkey,
796 SwapRecord: ex.Cid,
797 Record: &lexutil.LexiconTypeDecoder{
798 Val: &tangled.Repo{
799 Knot: f.Knot,
800 Name: f.Name,
801 Owner: user.Did,
802 CreatedAt: f.Created.Format(time.RFC3339),
803 Description: &f.Description,
804 Spindle: spindlePtr,
805 },
806 },
807 })
808
809 if err != nil {
810 fail("Failed to update spindle, unable to save to PDS.", err)
811 return
812 }
813
814 if !removingSpindle {
815 // add this spindle to spindle stream
816 rp.spindlestream.AddSource(
817 context.Background(),
818 eventconsumer.NewSpindleSource(newSpindle),
819 )
820 }
821
822 rp.pages.HxRefresh(w)
823}
824
825func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
826 user := rp.oauth.GetUser(r)
827 l := rp.logger.With("handler", "AddCollaborator")
828 l = l.With("did", user.Did)
829 l = l.With("handle", user.Handle)
830
831 f, err := rp.repoResolver.Resolve(r)
832 if err != nil {
833 l.Error("failed to get repo and knot", "err", err)
834 return
835 }
836
837 errorId := "add-collaborator-error"
838 fail := func(msg string, err error) {
839 l.Error(msg, "err", err)
840 rp.pages.Notice(w, errorId, msg)
841 }
842
843 collaborator := r.FormValue("collaborator")
844 if collaborator == "" {
845 fail("Invalid form.", nil)
846 return
847 }
848
849 // remove a single leading `@`, to make @handle work with ResolveIdent
850 collaborator = strings.TrimPrefix(collaborator, "@")
851
852 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
853 if err != nil {
854 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
855 return
856 }
857
858 if collaboratorIdent.DID.String() == user.Did {
859 fail("You seem to be adding yourself as a collaborator.", nil)
860 return
861 }
862 l = l.With("collaborator", collaboratorIdent.Handle)
863 l = l.With("knot", f.Knot)
864
865 // announce this relation into the firehose, store into owners' pds
866 client, err := rp.oauth.AuthorizedClient(r)
867 if err != nil {
868 fail("Failed to write to PDS.", err)
869 return
870 }
871
872 // emit a record
873 currentUser := rp.oauth.GetUser(r)
874 rkey := tid.TID()
875 createdAt := time.Now()
876 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
877 Collection: tangled.RepoCollaboratorNSID,
878 Repo: currentUser.Did,
879 Rkey: rkey,
880 Record: &lexutil.LexiconTypeDecoder{
881 Val: &tangled.RepoCollaborator{
882 Subject: collaboratorIdent.DID.String(),
883 Repo: string(f.RepoAt()),
884 CreatedAt: createdAt.Format(time.RFC3339),
885 }},
886 })
887 // invalid record
888 if err != nil {
889 fail("Failed to write record to PDS.", err)
890 return
891 }
892
893 aturi := resp.Uri
894 l = l.With("at-uri", aturi)
895 l.Info("wrote record to PDS")
896
897 tx, err := rp.db.BeginTx(r.Context(), nil)
898 if err != nil {
899 fail("Failed to add collaborator.", err)
900 return
901 }
902
903 rollback := func() {
904 err1 := tx.Rollback()
905 err2 := rp.enforcer.E.LoadPolicy()
906 err3 := rollbackRecord(context.Background(), aturi, client)
907
908 // ignore txn complete errors, this is okay
909 if errors.Is(err1, sql.ErrTxDone) {
910 err1 = nil
911 }
912
913 if errs := errors.Join(err1, err2, err3); errs != nil {
914 l.Error("failed to rollback changes", "errs", errs)
915 return
916 }
917 }
918 defer rollback()
919
920 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
921 if err != nil {
922 fail("Failed to add collaborator permissions.", err)
923 return
924 }
925
926 err = db.AddCollaborator(rp.db, db.Collaborator{
927 Did: syntax.DID(currentUser.Did),
928 Rkey: rkey,
929 SubjectDid: collaboratorIdent.DID,
930 RepoAt: f.RepoAt(),
931 Created: createdAt,
932 })
933 if err != nil {
934 fail("Failed to add collaborator.", err)
935 return
936 }
937
938 err = tx.Commit()
939 if err != nil {
940 fail("Failed to add collaborator.", err)
941 return
942 }
943
944 err = rp.enforcer.E.SavePolicy()
945 if err != nil {
946 fail("Failed to update collaborator permissions.", err)
947 return
948 }
949
950 // clear aturi to when everything is successful
951 aturi = ""
952
953 rp.pages.HxRefresh(w)
954}
955
956func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
957 user := rp.oauth.GetUser(r)
958
959 noticeId := "operation-error"
960 f, err := rp.repoResolver.Resolve(r)
961 if err != nil {
962 log.Println("failed to get repo and knot", err)
963 return
964 }
965
966 // remove record from pds
967 xrpcClient, err := rp.oauth.AuthorizedClient(r)
968 if err != nil {
969 log.Println("failed to get authorized client", err)
970 return
971 }
972 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
973 Collection: tangled.RepoNSID,
974 Repo: user.Did,
975 Rkey: f.Rkey,
976 })
977 if err != nil {
978 log.Printf("failed to delete record: %s", err)
979 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
980 return
981 }
982 log.Println("removed repo record ", f.RepoAt().String())
983
984 client, err := rp.oauth.ServiceClient(
985 r,
986 oauth.WithService(f.Knot),
987 oauth.WithLxm(tangled.RepoDeleteNSID),
988 oauth.WithDev(rp.config.Core.Dev),
989 )
990 if err != nil {
991 log.Println("failed to connect to knot server:", err)
992 return
993 }
994
995 err = tangled.RepoDelete(
996 r.Context(),
997 client,
998 &tangled.RepoDelete_Input{
999 Did: f.OwnerDid(),
1000 Name: f.Name,
1001 Rkey: f.Rkey,
1002 },
1003 )
1004 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1005 rp.pages.Notice(w, noticeId, err.Error())
1006 return
1007 }
1008 log.Println("deleted repo from knot")
1009
1010 tx, err := rp.db.BeginTx(r.Context(), nil)
1011 if err != nil {
1012 log.Println("failed to start tx")
1013 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1014 return
1015 }
1016 defer func() {
1017 tx.Rollback()
1018 err = rp.enforcer.E.LoadPolicy()
1019 if err != nil {
1020 log.Println("failed to rollback policies")
1021 }
1022 }()
1023
1024 // remove collaborator RBAC
1025 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1026 if err != nil {
1027 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1028 return
1029 }
1030 for _, c := range repoCollaborators {
1031 did := c[0]
1032 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1033 }
1034 log.Println("removed collaborators")
1035
1036 // remove repo RBAC
1037 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1038 if err != nil {
1039 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1040 return
1041 }
1042
1043 // remove repo from db
1044 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1045 if err != nil {
1046 rp.pages.Notice(w, noticeId, "Failed to update appview")
1047 return
1048 }
1049 log.Println("removed repo from db")
1050
1051 err = tx.Commit()
1052 if err != nil {
1053 log.Println("failed to commit changes", err)
1054 http.Error(w, err.Error(), http.StatusInternalServerError)
1055 return
1056 }
1057
1058 err = rp.enforcer.E.SavePolicy()
1059 if err != nil {
1060 log.Println("failed to update ACLs", err)
1061 http.Error(w, err.Error(), http.StatusInternalServerError)
1062 return
1063 }
1064
1065 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1066}
1067
1068func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1069 f, err := rp.repoResolver.Resolve(r)
1070 if err != nil {
1071 log.Println("failed to get repo and knot", err)
1072 return
1073 }
1074
1075 noticeId := "operation-error"
1076 branch := r.FormValue("branch")
1077 if branch == "" {
1078 http.Error(w, "malformed form", http.StatusBadRequest)
1079 return
1080 }
1081
1082 client, err := rp.oauth.ServiceClient(
1083 r,
1084 oauth.WithService(f.Knot),
1085 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1086 oauth.WithDev(rp.config.Core.Dev),
1087 )
1088 if err != nil {
1089 log.Println("failed to connect to knot server:", err)
1090 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1091 return
1092 }
1093
1094 xe := tangled.RepoSetDefaultBranch(
1095 r.Context(),
1096 client,
1097 &tangled.RepoSetDefaultBranch_Input{
1098 Repo: f.RepoAt().String(),
1099 DefaultBranch: branch,
1100 },
1101 )
1102 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1103 log.Println("xrpc failed", "err", xe)
1104 rp.pages.Notice(w, noticeId, err.Error())
1105 return
1106 }
1107
1108 rp.pages.HxRefresh(w)
1109}
1110
1111func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1112 user := rp.oauth.GetUser(r)
1113 l := rp.logger.With("handler", "Secrets")
1114 l = l.With("handle", user.Handle)
1115 l = l.With("did", user.Did)
1116
1117 f, err := rp.repoResolver.Resolve(r)
1118 if err != nil {
1119 log.Println("failed to get repo and knot", err)
1120 return
1121 }
1122
1123 if f.Spindle == "" {
1124 log.Println("empty spindle cannot add/rm secret", err)
1125 return
1126 }
1127
1128 lxm := tangled.RepoAddSecretNSID
1129 if r.Method == http.MethodDelete {
1130 lxm = tangled.RepoRemoveSecretNSID
1131 }
1132
1133 spindleClient, err := rp.oauth.ServiceClient(
1134 r,
1135 oauth.WithService(f.Spindle),
1136 oauth.WithLxm(lxm),
1137 oauth.WithExp(60),
1138 oauth.WithDev(rp.config.Core.Dev),
1139 )
1140 if err != nil {
1141 log.Println("failed to create spindle client", err)
1142 return
1143 }
1144
1145 key := r.FormValue("key")
1146 if key == "" {
1147 w.WriteHeader(http.StatusBadRequest)
1148 return
1149 }
1150
1151 switch r.Method {
1152 case http.MethodPut:
1153 errorId := "add-secret-error"
1154
1155 value := r.FormValue("value")
1156 if value == "" {
1157 w.WriteHeader(http.StatusBadRequest)
1158 return
1159 }
1160
1161 err = tangled.RepoAddSecret(
1162 r.Context(),
1163 spindleClient,
1164 &tangled.RepoAddSecret_Input{
1165 Repo: f.RepoAt().String(),
1166 Key: key,
1167 Value: value,
1168 },
1169 )
1170 if err != nil {
1171 l.Error("Failed to add secret.", "err", err)
1172 rp.pages.Notice(w, errorId, "Failed to add secret.")
1173 return
1174 }
1175
1176 case http.MethodDelete:
1177 errorId := "operation-error"
1178
1179 err = tangled.RepoRemoveSecret(
1180 r.Context(),
1181 spindleClient,
1182 &tangled.RepoRemoveSecret_Input{
1183 Repo: f.RepoAt().String(),
1184 Key: key,
1185 },
1186 )
1187 if err != nil {
1188 l.Error("Failed to delete secret.", "err", err)
1189 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1190 return
1191 }
1192 }
1193
1194 rp.pages.HxRefresh(w)
1195}
1196
1197type tab = map[string]any
1198
1199var (
1200 // would be great to have ordered maps right about now
1201 settingsTabs []tab = []tab{
1202 {"Name": "general", "Icon": "sliders-horizontal"},
1203 {"Name": "access", "Icon": "users"},
1204 {"Name": "pipelines", "Icon": "layers-2"},
1205 }
1206)
1207
1208func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1209 tabVal := r.URL.Query().Get("tab")
1210 if tabVal == "" {
1211 tabVal = "general"
1212 }
1213
1214 switch tabVal {
1215 case "general":
1216 rp.generalSettings(w, r)
1217
1218 case "access":
1219 rp.accessSettings(w, r)
1220
1221 case "pipelines":
1222 rp.pipelineSettings(w, r)
1223 }
1224}
1225
1226func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1227 f, err := rp.repoResolver.Resolve(r)
1228 user := rp.oauth.GetUser(r)
1229
1230 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1231 if err != nil {
1232 log.Println("failed to create unsigned client", err)
1233 return
1234 }
1235
1236 result, err := us.Branches(f.OwnerDid(), f.Name)
1237 if err != nil {
1238 rp.pages.Error503(w)
1239 log.Println("failed to reach knotserver", err)
1240 return
1241 }
1242
1243 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1244 LoggedInUser: user,
1245 RepoInfo: f.RepoInfo(user),
1246 Branches: result.Branches,
1247 Tabs: settingsTabs,
1248 Tab: "general",
1249 })
1250}
1251
1252func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1253 f, err := rp.repoResolver.Resolve(r)
1254 user := rp.oauth.GetUser(r)
1255
1256 repoCollaborators, err := f.Collaborators(r.Context())
1257 if err != nil {
1258 log.Println("failed to get collaborators", err)
1259 }
1260
1261 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1262 LoggedInUser: user,
1263 RepoInfo: f.RepoInfo(user),
1264 Tabs: settingsTabs,
1265 Tab: "access",
1266 Collaborators: repoCollaborators,
1267 })
1268}
1269
1270func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1271 f, err := rp.repoResolver.Resolve(r)
1272 user := rp.oauth.GetUser(r)
1273
1274 // all spindles that the repo owner is a member of
1275 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1276 if err != nil {
1277 log.Println("failed to fetch spindles", err)
1278 return
1279 }
1280
1281 var secrets []*tangled.RepoListSecrets_Secret
1282 if f.Spindle != "" {
1283 if spindleClient, err := rp.oauth.ServiceClient(
1284 r,
1285 oauth.WithService(f.Spindle),
1286 oauth.WithLxm(tangled.RepoListSecretsNSID),
1287 oauth.WithExp(60),
1288 oauth.WithDev(rp.config.Core.Dev),
1289 ); err != nil {
1290 log.Println("failed to create spindle client", err)
1291 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1292 log.Println("failed to fetch secrets", err)
1293 } else {
1294 secrets = resp.Secrets
1295 }
1296 }
1297
1298 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1299 return strings.Compare(a.Key, b.Key)
1300 })
1301
1302 var dids []string
1303 for _, s := range secrets {
1304 dids = append(dids, s.CreatedBy)
1305 }
1306 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1307
1308 // convert to a more manageable form
1309 var niceSecret []map[string]any
1310 for id, s := range secrets {
1311 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1312 niceSecret = append(niceSecret, map[string]any{
1313 "Id": id,
1314 "Key": s.Key,
1315 "CreatedAt": when,
1316 "CreatedBy": resolvedIdents[id].Handle.String(),
1317 })
1318 }
1319
1320 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1321 LoggedInUser: user,
1322 RepoInfo: f.RepoInfo(user),
1323 Tabs: settingsTabs,
1324 Tab: "pipelines",
1325 Spindles: spindles,
1326 CurrentSpindle: f.Spindle,
1327 Secrets: niceSecret,
1328 })
1329}
1330
1331func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1332 ref := chi.URLParam(r, "ref")
1333
1334 user := rp.oauth.GetUser(r)
1335 f, err := rp.repoResolver.Resolve(r)
1336 if err != nil {
1337 log.Printf("failed to resolve source repo: %v", err)
1338 return
1339 }
1340
1341 switch r.Method {
1342 case http.MethodPost:
1343 client, err := rp.oauth.ServiceClient(
1344 r,
1345 oauth.WithService(f.Knot),
1346 oauth.WithLxm(tangled.RepoForkSyncNSID),
1347 oauth.WithDev(rp.config.Core.Dev),
1348 )
1349 if err != nil {
1350 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1351 return
1352 }
1353
1354 repoInfo := f.RepoInfo(user)
1355 if repoInfo.Source == nil {
1356 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1357 return
1358 }
1359
1360 err = tangled.RepoForkSync(
1361 r.Context(),
1362 client,
1363 &tangled.RepoForkSync_Input{
1364 Did: user.Did,
1365 Name: f.Name,
1366 Source: repoInfo.Source.RepoAt().String(),
1367 Branch: ref,
1368 },
1369 )
1370 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1371 rp.pages.Notice(w, "repo", err.Error())
1372 return
1373 }
1374
1375 rp.pages.HxRefresh(w)
1376 return
1377 }
1378}
1379
1380func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1381 user := rp.oauth.GetUser(r)
1382 f, err := rp.repoResolver.Resolve(r)
1383 if err != nil {
1384 log.Printf("failed to resolve source repo: %v", err)
1385 return
1386 }
1387
1388 switch r.Method {
1389 case http.MethodGet:
1390 user := rp.oauth.GetUser(r)
1391 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1392 if err != nil {
1393 rp.pages.Notice(w, "repo", "Invalid user account.")
1394 return
1395 }
1396
1397 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1398 LoggedInUser: user,
1399 Knots: knots,
1400 RepoInfo: f.RepoInfo(user),
1401 })
1402
1403 case http.MethodPost:
1404 l := rp.logger.With("handler", "ForkRepo")
1405
1406 targetKnot := r.FormValue("knot")
1407 if targetKnot == "" {
1408 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1409 return
1410 }
1411 l = l.With("targetKnot", targetKnot)
1412
1413 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1414 if err != nil || !ok {
1415 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1416 return
1417 }
1418
1419 // choose a name for a fork
1420 forkName := f.Name
1421 // this check is *only* to see if the forked repo name already exists
1422 // in the user's account.
1423 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1424 if err != nil {
1425 if errors.Is(err, sql.ErrNoRows) {
1426 // no existing repo with this name found, we can use the name as is
1427 } else {
1428 log.Println("error fetching existing repo from db", err)
1429 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1430 return
1431 }
1432 } else if existingRepo != nil {
1433 // repo with this name already exists, append random string
1434 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1435 }
1436 l = l.With("forkName", forkName)
1437
1438 uri := "https"
1439 if rp.config.Core.Dev {
1440 uri = "http"
1441 }
1442
1443 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1444 l = l.With("cloneUrl", forkSourceUrl)
1445
1446 sourceAt := f.RepoAt().String()
1447
1448 // create an atproto record for this fork
1449 rkey := tid.TID()
1450 repo := &db.Repo{
1451 Did: user.Did,
1452 Name: forkName,
1453 Knot: targetKnot,
1454 Rkey: rkey,
1455 Source: sourceAt,
1456 }
1457
1458 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1459 if err != nil {
1460 l.Error("failed to create xrpcclient", "err", err)
1461 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1462 return
1463 }
1464
1465 createdAt := time.Now().Format(time.RFC3339)
1466 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1467 Collection: tangled.RepoNSID,
1468 Repo: user.Did,
1469 Rkey: rkey,
1470 Record: &lexutil.LexiconTypeDecoder{
1471 Val: &tangled.Repo{
1472 Knot: repo.Knot,
1473 Name: repo.Name,
1474 CreatedAt: createdAt,
1475 Owner: user.Did,
1476 Source: &sourceAt,
1477 }},
1478 })
1479 if err != nil {
1480 l.Error("failed to write to PDS", "err", err)
1481 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1482 return
1483 }
1484
1485 aturi := atresp.Uri
1486 l = l.With("aturi", aturi)
1487 l.Info("wrote to PDS")
1488
1489 tx, err := rp.db.BeginTx(r.Context(), nil)
1490 if err != nil {
1491 l.Info("txn failed", "err", err)
1492 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1493 return
1494 }
1495
1496 // The rollback function reverts a few things on failure:
1497 // - the pending txn
1498 // - the ACLs
1499 // - the atproto record created
1500 rollback := func() {
1501 err1 := tx.Rollback()
1502 err2 := rp.enforcer.E.LoadPolicy()
1503 err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1504
1505 // ignore txn complete errors, this is okay
1506 if errors.Is(err1, sql.ErrTxDone) {
1507 err1 = nil
1508 }
1509
1510 if errs := errors.Join(err1, err2, err3); errs != nil {
1511 l.Error("failed to rollback changes", "errs", errs)
1512 return
1513 }
1514 }
1515 defer rollback()
1516
1517 client, err := rp.oauth.ServiceClient(
1518 r,
1519 oauth.WithService(targetKnot),
1520 oauth.WithLxm(tangled.RepoCreateNSID),
1521 oauth.WithDev(rp.config.Core.Dev),
1522 )
1523 if err != nil {
1524 l.Error("could not create service client", "err", err)
1525 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1526 return
1527 }
1528
1529 err = tangled.RepoCreate(
1530 r.Context(),
1531 client,
1532 &tangled.RepoCreate_Input{
1533 Rkey: rkey,
1534 Source: &forkSourceUrl,
1535 },
1536 )
1537 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1538 rp.pages.Notice(w, "repo", err.Error())
1539 return
1540 }
1541
1542 err = db.AddRepo(tx, repo)
1543 if err != nil {
1544 log.Println(err)
1545 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1546 return
1547 }
1548
1549 // acls
1550 p, _ := securejoin.SecureJoin(user.Did, forkName)
1551 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1552 if err != nil {
1553 log.Println(err)
1554 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1555 return
1556 }
1557
1558 err = tx.Commit()
1559 if err != nil {
1560 log.Println("failed to commit changes", err)
1561 http.Error(w, err.Error(), http.StatusInternalServerError)
1562 return
1563 }
1564
1565 err = rp.enforcer.E.SavePolicy()
1566 if err != nil {
1567 log.Println("failed to update ACLs", err)
1568 http.Error(w, err.Error(), http.StatusInternalServerError)
1569 return
1570 }
1571
1572 // reset the ATURI because the transaction completed successfully
1573 aturi = ""
1574
1575 rp.notifier.NewRepo(r.Context(), repo)
1576 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1577 }
1578}
1579
1580// this is used to rollback changes made to the PDS
1581//
1582// it is a no-op if the provided ATURI is empty
1583func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1584 if aturi == "" {
1585 return nil
1586 }
1587
1588 parsed := syntax.ATURI(aturi)
1589
1590 collection := parsed.Collection().String()
1591 repo := parsed.Authority().String()
1592 rkey := parsed.RecordKey().String()
1593
1594 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1595 Collection: collection,
1596 Repo: repo,
1597 Rkey: rkey,
1598 })
1599 return err
1600}
1601
1602func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1603 user := rp.oauth.GetUser(r)
1604 f, err := rp.repoResolver.Resolve(r)
1605 if err != nil {
1606 log.Println("failed to get repo and knot", err)
1607 return
1608 }
1609
1610 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1611 if err != nil {
1612 log.Printf("failed to create unsigned client for %s", f.Knot)
1613 rp.pages.Error503(w)
1614 return
1615 }
1616
1617 result, err := us.Branches(f.OwnerDid(), f.Name)
1618 if err != nil {
1619 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1620 log.Println("failed to reach knotserver", err)
1621 return
1622 }
1623 branches := result.Branches
1624
1625 sortBranches(branches)
1626
1627 var defaultBranch string
1628 for _, b := range branches {
1629 if b.IsDefault {
1630 defaultBranch = b.Name
1631 }
1632 }
1633
1634 base := defaultBranch
1635 head := defaultBranch
1636
1637 params := r.URL.Query()
1638 queryBase := params.Get("base")
1639 queryHead := params.Get("head")
1640 if queryBase != "" {
1641 base = queryBase
1642 }
1643 if queryHead != "" {
1644 head = queryHead
1645 }
1646
1647 tags, err := us.Tags(f.OwnerDid(), f.Name)
1648 if err != nil {
1649 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1650 log.Println("failed to reach knotserver", err)
1651 return
1652 }
1653
1654 repoinfo := f.RepoInfo(user)
1655
1656 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1657 LoggedInUser: user,
1658 RepoInfo: repoinfo,
1659 Branches: branches,
1660 Tags: tags.Tags,
1661 Base: base,
1662 Head: head,
1663 })
1664}
1665
1666func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1667 user := rp.oauth.GetUser(r)
1668 f, err := rp.repoResolver.Resolve(r)
1669 if err != nil {
1670 log.Println("failed to get repo and knot", err)
1671 return
1672 }
1673
1674 var diffOpts types.DiffOpts
1675 if d := r.URL.Query().Get("diff"); d == "split" {
1676 diffOpts.Split = true
1677 }
1678
1679 // if user is navigating to one of
1680 // /compare/{base}/{head}
1681 // /compare/{base}...{head}
1682 base := chi.URLParam(r, "base")
1683 head := chi.URLParam(r, "head")
1684 if base == "" && head == "" {
1685 rest := chi.URLParam(r, "*") // master...feature/xyz
1686 parts := strings.SplitN(rest, "...", 2)
1687 if len(parts) == 2 {
1688 base = parts[0]
1689 head = parts[1]
1690 }
1691 }
1692
1693 base, _ = url.PathUnescape(base)
1694 head, _ = url.PathUnescape(head)
1695
1696 if base == "" || head == "" {
1697 log.Printf("invalid comparison")
1698 rp.pages.Error404(w)
1699 return
1700 }
1701
1702 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1703 if err != nil {
1704 log.Printf("failed to create unsigned client for %s", f.Knot)
1705 rp.pages.Error503(w)
1706 return
1707 }
1708
1709 branches, err := us.Branches(f.OwnerDid(), f.Name)
1710 if err != nil {
1711 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1712 log.Println("failed to reach knotserver", err)
1713 return
1714 }
1715
1716 tags, err := us.Tags(f.OwnerDid(), f.Name)
1717 if err != nil {
1718 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1719 log.Println("failed to reach knotserver", err)
1720 return
1721 }
1722
1723 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1724 if err != nil {
1725 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1726 log.Println("failed to compare", err)
1727 return
1728 }
1729 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1730
1731 repoinfo := f.RepoInfo(user)
1732
1733 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1734 LoggedInUser: user,
1735 RepoInfo: repoinfo,
1736 Branches: branches.Branches,
1737 Tags: tags.Tags,
1738 Base: base,
1739 Head: head,
1740 Diff: &diff,
1741 DiffOpts: diffOpts,
1742 })
1743
1744}