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