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.WithExp(60),
1066 oauth.WithDev(rp.config.Core.Dev),
1067 )
1068 if err != nil {
1069 log.Println("failed to create spindle client", err)
1070 return
1071 }
1072
1073 key := r.FormValue("key")
1074 if key == "" {
1075 w.WriteHeader(http.StatusBadRequest)
1076 return
1077 }
1078
1079 switch r.Method {
1080 case http.MethodPut:
1081 errorId := "add-secret-error"
1082
1083 value := r.FormValue("value")
1084 if value == "" {
1085 w.WriteHeader(http.StatusBadRequest)
1086 return
1087 }
1088
1089 err = tangled.RepoAddSecret(
1090 r.Context(),
1091 spindleClient,
1092 &tangled.RepoAddSecret_Input{
1093 Repo: f.RepoAt.String(),
1094 Key: key,
1095 Value: value,
1096 },
1097 )
1098 if err != nil {
1099 l.Error("Failed to add secret.", "err", err)
1100 rp.pages.Notice(w, errorId, "Failed to add secret.")
1101 return
1102 }
1103
1104 case http.MethodDelete:
1105 errorId := "operation-error"
1106
1107 err = tangled.RepoRemoveSecret(
1108 r.Context(),
1109 spindleClient,
1110 &tangled.RepoRemoveSecret_Input{
1111 Repo: f.RepoAt.String(),
1112 Key: key,
1113 },
1114 )
1115 if err != nil {
1116 l.Error("Failed to delete secret.", "err", err)
1117 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1118 return
1119 }
1120 }
1121
1122 rp.pages.HxRefresh(w)
1123}
1124
1125type tab = map[string]any
1126
1127var (
1128 // would be great to have ordered maps right about now
1129 settingsTabs []tab = []tab{
1130 {"Name": "general", "Icon": "sliders-horizontal"},
1131 {"Name": "access", "Icon": "users"},
1132 {"Name": "pipelines", "Icon": "layers-2"},
1133 }
1134)
1135
1136func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1137 tabVal := r.URL.Query().Get("tab")
1138 if tabVal == "" {
1139 tabVal = "general"
1140 }
1141
1142 switch tabVal {
1143 case "general":
1144 rp.generalSettings(w, r)
1145
1146 case "access":
1147 rp.accessSettings(w, r)
1148
1149 case "pipelines":
1150 rp.pipelineSettings(w, r)
1151 }
1152
1153 // user := rp.oauth.GetUser(r)
1154 // repoCollaborators, err := f.Collaborators(r.Context())
1155 // if err != nil {
1156 // log.Println("failed to get collaborators", err)
1157 // }
1158
1159 // isCollaboratorInviteAllowed := false
1160 // if user != nil {
1161 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1162 // if err == nil && ok {
1163 // isCollaboratorInviteAllowed = true
1164 // }
1165 // }
1166
1167 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1168 // if err != nil {
1169 // log.Println("failed to create unsigned client", err)
1170 // return
1171 // }
1172
1173 // result, err := us.Branches(f.OwnerDid(), f.RepoName)
1174 // if err != nil {
1175 // log.Println("failed to reach knotserver", err)
1176 // return
1177 // }
1178
1179 // // all spindles that this user is a member of
1180 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1181 // if err != nil {
1182 // log.Println("failed to fetch spindles", err)
1183 // return
1184 // }
1185
1186 // var secrets []*tangled.RepoListSecrets_Secret
1187 // if f.Spindle != "" {
1188 // if spindleClient, err := rp.oauth.ServiceClient(
1189 // r,
1190 // oauth.WithService(f.Spindle),
1191 // oauth.WithLxm(tangled.RepoListSecretsNSID),
1192 // oauth.WithDev(rp.config.Core.Dev),
1193 // ); err != nil {
1194 // log.Println("failed to create spindle client", err)
1195 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1196 // log.Println("failed to fetch secrets", err)
1197 // } else {
1198 // secrets = resp.Secrets
1199 // }
1200 // }
1201
1202 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1203 // LoggedInUser: user,
1204 // RepoInfo: f.RepoInfo(user),
1205 // Collaborators: repoCollaborators,
1206 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1207 // Branches: result.Branches,
1208 // Spindles: spindles,
1209 // CurrentSpindle: f.Spindle,
1210 // Secrets: secrets,
1211 // })
1212}
1213
1214func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1215 f, err := rp.repoResolver.Resolve(r)
1216 user := rp.oauth.GetUser(r)
1217
1218 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1219 if err != nil {
1220 log.Println("failed to create unsigned client", err)
1221 return
1222 }
1223
1224 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1225 if err != nil {
1226 log.Println("failed to reach knotserver", err)
1227 return
1228 }
1229
1230 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1231 LoggedInUser: user,
1232 RepoInfo: f.RepoInfo(user),
1233 Branches: result.Branches,
1234 Tabs: settingsTabs,
1235 Tab: "general",
1236 })
1237}
1238
1239func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1240 f, err := rp.repoResolver.Resolve(r)
1241 user := rp.oauth.GetUser(r)
1242
1243 repoCollaborators, err := f.Collaborators(r.Context())
1244 if err != nil {
1245 log.Println("failed to get collaborators", err)
1246 }
1247
1248 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1249 LoggedInUser: user,
1250 RepoInfo: f.RepoInfo(user),
1251 Tabs: settingsTabs,
1252 Tab: "access",
1253 Collaborators: repoCollaborators,
1254 })
1255}
1256
1257func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1258 f, err := rp.repoResolver.Resolve(r)
1259 user := rp.oauth.GetUser(r)
1260
1261 // all spindles that the repo owner is a member of
1262 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1263 if err != nil {
1264 log.Println("failed to fetch spindles", err)
1265 return
1266 }
1267
1268 var secrets []*tangled.RepoListSecrets_Secret
1269 if f.Spindle != "" {
1270 if spindleClient, err := rp.oauth.ServiceClient(
1271 r,
1272 oauth.WithService(f.Spindle),
1273 oauth.WithLxm(tangled.RepoListSecretsNSID),
1274 oauth.WithExp(60),
1275 oauth.WithDev(rp.config.Core.Dev),
1276 ); err != nil {
1277 log.Println("failed to create spindle client", err)
1278 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1279 log.Println("failed to fetch secrets", err)
1280 } else {
1281 secrets = resp.Secrets
1282 }
1283 }
1284
1285 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1286 return strings.Compare(a.Key, b.Key)
1287 })
1288
1289 var dids []string
1290 for _, s := range secrets {
1291 dids = append(dids, s.CreatedBy)
1292 }
1293 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1294
1295 // convert to a more manageable form
1296 var niceSecret []map[string]any
1297 for id, s := range secrets {
1298 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1299 niceSecret = append(niceSecret, map[string]any{
1300 "Id": id,
1301 "Key": s.Key,
1302 "CreatedAt": when,
1303 "CreatedBy": resolvedIdents[id].Handle.String(),
1304 })
1305 }
1306
1307 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1308 LoggedInUser: user,
1309 RepoInfo: f.RepoInfo(user),
1310 Tabs: settingsTabs,
1311 Tab: "pipelines",
1312 Spindles: spindles,
1313 CurrentSpindle: f.Spindle,
1314 Secrets: niceSecret,
1315 })
1316}
1317
1318func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1319 user := rp.oauth.GetUser(r)
1320 f, err := rp.repoResolver.Resolve(r)
1321 if err != nil {
1322 log.Printf("failed to resolve source repo: %v", err)
1323 return
1324 }
1325
1326 switch r.Method {
1327 case http.MethodPost:
1328 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1329 if err != nil {
1330 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1331 return
1332 }
1333
1334 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1335 if err != nil {
1336 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1337 return
1338 }
1339
1340 var uri string
1341 if rp.config.Core.Dev {
1342 uri = "http"
1343 } else {
1344 uri = "https"
1345 }
1346 forkName := fmt.Sprintf("%s", f.RepoName)
1347 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1348
1349 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1350 if err != nil {
1351 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1352 return
1353 }
1354
1355 rp.pages.HxRefresh(w)
1356 return
1357 }
1358}
1359
1360func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1361 user := rp.oauth.GetUser(r)
1362 f, err := rp.repoResolver.Resolve(r)
1363 if err != nil {
1364 log.Printf("failed to resolve source repo: %v", err)
1365 return
1366 }
1367
1368 switch r.Method {
1369 case http.MethodGet:
1370 user := rp.oauth.GetUser(r)
1371 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1372 if err != nil {
1373 rp.pages.Notice(w, "repo", "Invalid user account.")
1374 return
1375 }
1376
1377 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1378 LoggedInUser: user,
1379 Knots: knots,
1380 RepoInfo: f.RepoInfo(user),
1381 })
1382
1383 case http.MethodPost:
1384
1385 knot := r.FormValue("knot")
1386 if knot == "" {
1387 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1388 return
1389 }
1390
1391 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1392 if err != nil || !ok {
1393 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1394 return
1395 }
1396
1397 forkName := fmt.Sprintf("%s", f.RepoName)
1398
1399 // this check is *only* to see if the forked repo name already exists
1400 // in the user's account.
1401 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1402 if err != nil {
1403 if errors.Is(err, sql.ErrNoRows) {
1404 // no existing repo with this name found, we can use the name as is
1405 } else {
1406 log.Println("error fetching existing repo from db", err)
1407 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1408 return
1409 }
1410 } else if existingRepo != nil {
1411 // repo with this name already exists, append random string
1412 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1413 }
1414 secret, err := db.GetRegistrationKey(rp.db, knot)
1415 if err != nil {
1416 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1417 return
1418 }
1419
1420 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1421 if err != nil {
1422 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1423 return
1424 }
1425
1426 var uri string
1427 if rp.config.Core.Dev {
1428 uri = "http"
1429 } else {
1430 uri = "https"
1431 }
1432 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1433 sourceAt := f.RepoAt.String()
1434
1435 rkey := tid.TID()
1436 repo := &db.Repo{
1437 Did: user.Did,
1438 Name: forkName,
1439 Knot: knot,
1440 Rkey: rkey,
1441 Source: sourceAt,
1442 }
1443
1444 tx, err := rp.db.BeginTx(r.Context(), nil)
1445 if err != nil {
1446 log.Println(err)
1447 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1448 return
1449 }
1450 defer func() {
1451 tx.Rollback()
1452 err = rp.enforcer.E.LoadPolicy()
1453 if err != nil {
1454 log.Println("failed to rollback policies")
1455 }
1456 }()
1457
1458 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1459 if err != nil {
1460 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1461 return
1462 }
1463
1464 switch resp.StatusCode {
1465 case http.StatusConflict:
1466 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1467 return
1468 case http.StatusInternalServerError:
1469 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1470 case http.StatusNoContent:
1471 // continue
1472 }
1473
1474 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1475 if err != nil {
1476 log.Println("failed to get authorized client", err)
1477 rp.pages.Notice(w, "repo", "Failed to create repository.")
1478 return
1479 }
1480
1481 createdAt := time.Now().Format(time.RFC3339)
1482 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1483 Collection: tangled.RepoNSID,
1484 Repo: user.Did,
1485 Rkey: rkey,
1486 Record: &lexutil.LexiconTypeDecoder{
1487 Val: &tangled.Repo{
1488 Knot: repo.Knot,
1489 Name: repo.Name,
1490 CreatedAt: createdAt,
1491 Owner: user.Did,
1492 Source: &sourceAt,
1493 }},
1494 })
1495 if err != nil {
1496 log.Printf("failed to create record: %s", err)
1497 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1498 return
1499 }
1500 log.Println("created repo record: ", atresp.Uri)
1501
1502 repo.AtUri = atresp.Uri
1503 err = db.AddRepo(tx, repo)
1504 if err != nil {
1505 log.Println(err)
1506 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1507 return
1508 }
1509
1510 // acls
1511 p, _ := securejoin.SecureJoin(user.Did, forkName)
1512 err = rp.enforcer.AddRepo(user.Did, knot, p)
1513 if err != nil {
1514 log.Println(err)
1515 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1516 return
1517 }
1518
1519 err = tx.Commit()
1520 if err != nil {
1521 log.Println("failed to commit changes", err)
1522 http.Error(w, err.Error(), http.StatusInternalServerError)
1523 return
1524 }
1525
1526 err = rp.enforcer.E.SavePolicy()
1527 if err != nil {
1528 log.Println("failed to update ACLs", err)
1529 http.Error(w, err.Error(), http.StatusInternalServerError)
1530 return
1531 }
1532
1533 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1534 return
1535 }
1536}
1537
1538func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1539 user := rp.oauth.GetUser(r)
1540 f, err := rp.repoResolver.Resolve(r)
1541 if err != nil {
1542 log.Println("failed to get repo and knot", err)
1543 return
1544 }
1545
1546 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1547 if err != nil {
1548 log.Printf("failed to create unsigned client for %s", f.Knot)
1549 rp.pages.Error503(w)
1550 return
1551 }
1552
1553 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1554 if err != nil {
1555 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1556 log.Println("failed to reach knotserver", err)
1557 return
1558 }
1559 branches := result.Branches
1560
1561 sortBranches(branches)
1562
1563 var defaultBranch string
1564 for _, b := range branches {
1565 if b.IsDefault {
1566 defaultBranch = b.Name
1567 }
1568 }
1569
1570 base := defaultBranch
1571 head := defaultBranch
1572
1573 params := r.URL.Query()
1574 queryBase := params.Get("base")
1575 queryHead := params.Get("head")
1576 if queryBase != "" {
1577 base = queryBase
1578 }
1579 if queryHead != "" {
1580 head = queryHead
1581 }
1582
1583 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1584 if err != nil {
1585 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1586 log.Println("failed to reach knotserver", err)
1587 return
1588 }
1589
1590 repoinfo := f.RepoInfo(user)
1591
1592 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1593 LoggedInUser: user,
1594 RepoInfo: repoinfo,
1595 Branches: branches,
1596 Tags: tags.Tags,
1597 Base: base,
1598 Head: head,
1599 })
1600}
1601
1602func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1603 user := rp.oauth.GetUser(r)
1604 f, err := rp.repoResolver.Resolve(r)
1605 if err != nil {
1606 log.Println("failed to get repo and knot", err)
1607 return
1608 }
1609
1610 var diffOpts types.DiffOpts
1611 if d := r.URL.Query().Get("diff"); d == "split" {
1612 diffOpts.Split = true
1613 }
1614
1615 // if user is navigating to one of
1616 // /compare/{base}/{head}
1617 // /compare/{base}...{head}
1618 base := chi.URLParam(r, "base")
1619 head := chi.URLParam(r, "head")
1620 if base == "" && head == "" {
1621 rest := chi.URLParam(r, "*") // master...feature/xyz
1622 parts := strings.SplitN(rest, "...", 2)
1623 if len(parts) == 2 {
1624 base = parts[0]
1625 head = parts[1]
1626 }
1627 }
1628
1629 base, _ = url.PathUnescape(base)
1630 head, _ = url.PathUnescape(head)
1631
1632 if base == "" || head == "" {
1633 log.Printf("invalid comparison")
1634 rp.pages.Error404(w)
1635 return
1636 }
1637
1638 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1639 if err != nil {
1640 log.Printf("failed to create unsigned client for %s", f.Knot)
1641 rp.pages.Error503(w)
1642 return
1643 }
1644
1645 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1646 if err != nil {
1647 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1648 log.Println("failed to reach knotserver", err)
1649 return
1650 }
1651
1652 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1653 if err != nil {
1654 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1655 log.Println("failed to reach knotserver", err)
1656 return
1657 }
1658
1659 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1660 if err != nil {
1661 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1662 log.Println("failed to compare", err)
1663 return
1664 }
1665 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1666
1667 repoinfo := f.RepoInfo(user)
1668
1669 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1670 LoggedInUser: user,
1671 RepoInfo: repoinfo,
1672 Branches: branches.Branches,
1673 Tags: tags.Tags,
1674 Base: base,
1675 Head: head,
1676 Diff: &diff,
1677 DiffOpts: diffOpts,
1678 })
1679
1680}