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