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