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