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