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