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