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