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