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