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