forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "net/url"
13 "path"
14 "slices"
15 "sort"
16 "strconv"
17 "strings"
18 "time"
19
20 "tangled.sh/tangled.sh/core/api/tangled"
21 "tangled.sh/tangled.sh/core/appview"
22 "tangled.sh/tangled.sh/core/appview/commitverify"
23 "tangled.sh/tangled.sh/core/appview/config"
24 "tangled.sh/tangled.sh/core/appview/db"
25 "tangled.sh/tangled.sh/core/appview/idresolver"
26 "tangled.sh/tangled.sh/core/appview/oauth"
27 "tangled.sh/tangled.sh/core/appview/pages"
28 "tangled.sh/tangled.sh/core/appview/pages/markup"
29 "tangled.sh/tangled.sh/core/appview/reporesolver"
30 "tangled.sh/tangled.sh/core/eventconsumer"
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/types"
35
36 securejoin "github.com/cyphar/filepath-securejoin"
37 "github.com/go-chi/chi/v5"
38 "github.com/go-git/go-git/v5/plumbing"
39 "github.com/posthog/posthog-go"
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 posthog posthog.Client
55}
56
57func New(
58 oauth *oauth.OAuth,
59 repoResolver *reporesolver.RepoResolver,
60 pages *pages.Pages,
61 spindlestream *eventconsumer.Consumer,
62 idResolver *idresolver.Resolver,
63 db *db.DB,
64 config *config.Config,
65 posthog posthog.Client,
66 enforcer *rbac.Enforcer,
67) *Repo {
68 return &Repo{oauth: oauth,
69 repoResolver: repoResolver,
70 pages: pages,
71 idResolver: idResolver,
72 config: config,
73 spindlestream: spindlestream,
74 db: db,
75 posthog: posthog,
76 enforcer: enforcer,
77 }
78}
79
80func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
81 f, err := rp.repoResolver.Resolve(r)
82 if err != nil {
83 log.Println("failed to fully resolve repo", err)
84 return
85 }
86
87 page := 1
88 if r.URL.Query().Get("page") != "" {
89 page, err = strconv.Atoi(r.URL.Query().Get("page"))
90 if err != nil {
91 page = 1
92 }
93 }
94
95 ref := chi.URLParam(r, "ref")
96
97 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
98 if err != nil {
99 log.Println("failed to create unsigned client", err)
100 return
101 }
102
103 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
104 if err != nil {
105 log.Println("failed to reach knotserver", err)
106 return
107 }
108
109 tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
110 if err != nil {
111 log.Println("failed to reach knotserver", err)
112 return
113 }
114
115 tagMap := make(map[string][]string)
116 for _, tag := range tagResult.Tags {
117 hash := tag.Hash
118 if tag.Tag != nil {
119 hash = tag.Tag.Target.String()
120 }
121 tagMap[hash] = append(tagMap[hash], tag.Name)
122 }
123
124 branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
125 if err != nil {
126 log.Println("failed to reach knotserver", err)
127 return
128 }
129
130 for _, branch := range branchResult.Branches {
131 hash := branch.Hash
132 tagMap[hash] = append(tagMap[hash], branch.Name)
133 }
134
135 user := rp.oauth.GetUser(r)
136
137 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
138 if err != nil {
139 log.Println("failed to fetch email to did mapping", err)
140 }
141
142 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
143 if err != nil {
144 log.Println(err)
145 }
146
147 repoInfo := f.RepoInfo(user)
148
149 var shas []string
150 for _, c := range repolog.Commits {
151 shas = append(shas, c.Hash.String())
152 }
153 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
154 if err != nil {
155 log.Println(err)
156 // non-fatal
157 }
158
159 rp.pages.RepoLog(w, pages.RepoLogParams{
160 LoggedInUser: user,
161 TagMap: tagMap,
162 RepoInfo: repoInfo,
163 RepoLogResponse: *repolog,
164 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
165 VerifiedCommits: vc,
166 Pipelines: pipelines,
167 })
168}
169
170func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
171 f, err := rp.repoResolver.Resolve(r)
172 if err != nil {
173 log.Println("failed to get repo and knot", err)
174 w.WriteHeader(http.StatusBadRequest)
175 return
176 }
177
178 user := rp.oauth.GetUser(r)
179 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
180 RepoInfo: f.RepoInfo(user),
181 })
182 return
183}
184
185func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
186 f, err := rp.repoResolver.Resolve(r)
187 if err != nil {
188 log.Println("failed to get repo and knot", err)
189 w.WriteHeader(http.StatusBadRequest)
190 return
191 }
192
193 repoAt := f.RepoAt
194 rkey := repoAt.RecordKey().String()
195 if rkey == "" {
196 log.Println("invalid aturi for repo", err)
197 w.WriteHeader(http.StatusInternalServerError)
198 return
199 }
200
201 user := rp.oauth.GetUser(r)
202
203 switch r.Method {
204 case http.MethodGet:
205 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
206 RepoInfo: f.RepoInfo(user),
207 })
208 return
209 case http.MethodPut:
210 newDescription := r.FormValue("description")
211 client, err := rp.oauth.AuthorizedClient(r)
212 if err != nil {
213 log.Println("failed to get client")
214 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
215 return
216 }
217
218 // optimistic update
219 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
220 if err != nil {
221 log.Println("failed to perferom update-description query", err)
222 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
223 return
224 }
225
226 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
227 //
228 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
229 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
230 if err != nil {
231 // failed to get record
232 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
233 return
234 }
235 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
236 Collection: tangled.RepoNSID,
237 Repo: user.Did,
238 Rkey: rkey,
239 SwapRecord: ex.Cid,
240 Record: &lexutil.LexiconTypeDecoder{
241 Val: &tangled.Repo{
242 Knot: f.Knot,
243 Name: f.RepoName,
244 Owner: user.Did,
245 CreatedAt: f.CreatedAt,
246 Description: &newDescription,
247 Spindle: &f.Spindle,
248 },
249 },
250 })
251
252 if err != nil {
253 log.Println("failed to perferom update-description query", err)
254 // failed to get record
255 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
256 return
257 }
258
259 newRepoInfo := f.RepoInfo(user)
260 newRepoInfo.Description = newDescription
261
262 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
263 RepoInfo: newRepoInfo,
264 })
265 return
266 }
267}
268
269func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
270 f, err := rp.repoResolver.Resolve(r)
271 if err != nil {
272 log.Println("failed to fully resolve repo", err)
273 return
274 }
275 ref := chi.URLParam(r, "ref")
276 protocol := "http"
277 if !rp.config.Core.Dev {
278 protocol = "https"
279 }
280
281 var diffOpts types.DiffOpts
282 if d := r.URL.Query().Get("diff"); d == "split" {
283 diffOpts.Split = true
284 }
285
286 if !plumbing.IsHash(ref) {
287 rp.pages.Error404(w)
288 return
289 }
290
291 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
292 if err != nil {
293 log.Println("failed to reach knotserver", err)
294 return
295 }
296
297 body, err := io.ReadAll(resp.Body)
298 if err != nil {
299 log.Printf("Error reading response body: %v", err)
300 return
301 }
302
303 var result types.RepoCommitResponse
304 err = json.Unmarshal(body, &result)
305 if err != nil {
306 log.Println("failed to parse response:", err)
307 return
308 }
309
310 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
311 if err != nil {
312 log.Println("failed to get email to did mapping:", err)
313 }
314
315 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
316 if err != nil {
317 log.Println(err)
318 }
319
320 user := rp.oauth.GetUser(r)
321 repoInfo := f.RepoInfo(user)
322 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
323 if err != nil {
324 log.Println(err)
325 // non-fatal
326 }
327 var pipeline *db.Pipeline
328 if p, ok := pipelines[result.Diff.Commit.This]; ok {
329 pipeline = &p
330 }
331
332 rp.pages.RepoCommit(w, pages.RepoCommitParams{
333 LoggedInUser: user,
334 RepoInfo: f.RepoInfo(user),
335 RepoCommitResponse: result,
336 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
337 VerifiedCommit: vc,
338 Pipeline: pipeline,
339 DiffOpts: diffOpts,
340 })
341}
342
343func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
344 f, err := rp.repoResolver.Resolve(r)
345 if err != nil {
346 log.Println("failed to fully resolve repo", err)
347 return
348 }
349
350 ref := chi.URLParam(r, "ref")
351 treePath := chi.URLParam(r, "*")
352 protocol := "http"
353 if !rp.config.Core.Dev {
354 protocol = "https"
355 }
356 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
357 if err != nil {
358 log.Println("failed to reach knotserver", err)
359 return
360 }
361
362 body, err := io.ReadAll(resp.Body)
363 if err != nil {
364 log.Printf("Error reading response body: %v", err)
365 return
366 }
367
368 var result types.RepoTreeResponse
369 err = json.Unmarshal(body, &result)
370 if err != nil {
371 log.Println("failed to parse response:", err)
372 return
373 }
374
375 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
376 // so we can safely redirect to the "parent" (which is the same file).
377 if len(result.Files) == 0 && result.Parent == treePath {
378 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
379 return
380 }
381
382 user := rp.oauth.GetUser(r)
383
384 var breadcrumbs [][]string
385 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
386 if treePath != "" {
387 for idx, elem := range strings.Split(treePath, "/") {
388 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
389 }
390 }
391
392 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
393 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
394
395 rp.pages.RepoTree(w, pages.RepoTreeParams{
396 LoggedInUser: user,
397 BreadCrumbs: breadcrumbs,
398 BaseTreeLink: baseTreeLink,
399 BaseBlobLink: baseBlobLink,
400 RepoInfo: f.RepoInfo(user),
401 RepoTreeResponse: result,
402 })
403 return
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 return
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 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
484 if a.IsDefault {
485 return -1
486 }
487 if b.IsDefault {
488 return 1
489 }
490 if a.Commit != nil && b.Commit != nil {
491 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
492 return 1
493 } else {
494 return -1
495 }
496 }
497 return strings.Compare(a.Name, b.Name) * -1
498 })
499
500 user := rp.oauth.GetUser(r)
501 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
502 LoggedInUser: user,
503 RepoInfo: f.RepoInfo(user),
504 RepoBranchesResponse: *result,
505 })
506 return
507}
508
509func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
510 f, err := rp.repoResolver.Resolve(r)
511 if err != nil {
512 log.Println("failed to get repo and knot", err)
513 return
514 }
515
516 ref := chi.URLParam(r, "ref")
517 filePath := chi.URLParam(r, "*")
518 protocol := "http"
519 if !rp.config.Core.Dev {
520 protocol = "https"
521 }
522 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
523 if err != nil {
524 log.Println("failed to reach knotserver", err)
525 return
526 }
527
528 body, err := io.ReadAll(resp.Body)
529 if err != nil {
530 log.Printf("Error reading response body: %v", err)
531 return
532 }
533
534 var result types.RepoBlobResponse
535 err = json.Unmarshal(body, &result)
536 if err != nil {
537 log.Println("failed to parse response:", err)
538 return
539 }
540
541 var breadcrumbs [][]string
542 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
543 if filePath != "" {
544 for idx, elem := range strings.Split(filePath, "/") {
545 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
546 }
547 }
548
549 showRendered := false
550 renderToggle := false
551
552 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
553 renderToggle = true
554 showRendered = r.URL.Query().Get("code") != "true"
555 }
556
557 user := rp.oauth.GetUser(r)
558 rp.pages.RepoBlob(w, pages.RepoBlobParams{
559 LoggedInUser: user,
560 RepoInfo: f.RepoInfo(user),
561 RepoBlobResponse: result,
562 BreadCrumbs: breadcrumbs,
563 ShowRendered: showRendered,
564 RenderToggle: renderToggle,
565 })
566 return
567}
568
569func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
570 f, err := rp.repoResolver.Resolve(r)
571 if err != nil {
572 log.Println("failed to get repo and knot", err)
573 return
574 }
575
576 ref := chi.URLParam(r, "ref")
577 filePath := chi.URLParam(r, "*")
578
579 protocol := "http"
580 if !rp.config.Core.Dev {
581 protocol = "https"
582 }
583 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
584 if err != nil {
585 log.Println("failed to reach knotserver", err)
586 return
587 }
588
589 body, err := io.ReadAll(resp.Body)
590 if err != nil {
591 log.Printf("Error reading response body: %v", err)
592 return
593 }
594
595 var result types.RepoBlobResponse
596 err = json.Unmarshal(body, &result)
597 if err != nil {
598 log.Println("failed to parse response:", err)
599 return
600 }
601
602 if result.IsBinary {
603 w.Header().Set("Content-Type", "application/octet-stream")
604 w.Write(body)
605 return
606 }
607
608 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
609 w.Write([]byte(result.Contents))
610 return
611}
612
613// modify the spindle configured for this repo
614func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
615 f, err := rp.repoResolver.Resolve(r)
616 if err != nil {
617 log.Println("failed to get repo and knot", err)
618 w.WriteHeader(http.StatusBadRequest)
619 return
620 }
621
622 repoAt := f.RepoAt
623 rkey := repoAt.RecordKey().String()
624 if rkey == "" {
625 log.Println("invalid aturi for repo", err)
626 w.WriteHeader(http.StatusInternalServerError)
627 return
628 }
629
630 user := rp.oauth.GetUser(r)
631
632 newSpindle := r.FormValue("spindle")
633 client, err := rp.oauth.AuthorizedClient(r)
634 if err != nil {
635 log.Println("failed to get client")
636 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
637 return
638 }
639
640 // ensure that this is a valid spindle for this user
641 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
642 if err != nil {
643 log.Println("failed to get valid spindles")
644 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
645 return
646 }
647
648 if !slices.Contains(validSpindles, newSpindle) {
649 log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
650 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
651 return
652 }
653
654 // optimistic update
655 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
656 if err != nil {
657 log.Println("failed to perform update-spindle query", err)
658 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
659 return
660 }
661
662 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
663 if err != nil {
664 // failed to get record
665 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
666 return
667 }
668 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
669 Collection: tangled.RepoNSID,
670 Repo: user.Did,
671 Rkey: rkey,
672 SwapRecord: ex.Cid,
673 Record: &lexutil.LexiconTypeDecoder{
674 Val: &tangled.Repo{
675 Knot: f.Knot,
676 Name: f.RepoName,
677 Owner: user.Did,
678 CreatedAt: f.CreatedAt,
679 Description: &f.Description,
680 Spindle: &newSpindle,
681 },
682 },
683 })
684
685 if err != nil {
686 log.Println("failed to perform update-spindle query", err)
687 // failed to get record
688 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
689 return
690 }
691
692 // add this spindle to spindle stream
693 rp.spindlestream.AddSource(
694 context.Background(),
695 eventconsumer.NewSpindleSource(newSpindle),
696 )
697
698 w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
699}
700
701func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
702 f, err := rp.repoResolver.Resolve(r)
703 if err != nil {
704 log.Println("failed to get repo and knot", err)
705 return
706 }
707
708 collaborator := r.FormValue("collaborator")
709 if collaborator == "" {
710 http.Error(w, "malformed form", http.StatusBadRequest)
711 return
712 }
713
714 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
715 if err != nil {
716 w.Write([]byte("failed to resolve collaborator did to a handle"))
717 return
718 }
719 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
720
721 // TODO: create an atproto record for this
722
723 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
724 if err != nil {
725 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
726 return
727 }
728
729 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
730 if err != nil {
731 log.Println("failed to create client to ", f.Knot)
732 return
733 }
734
735 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
736 if err != nil {
737 log.Printf("failed to make request to %s: %s", f.Knot, err)
738 return
739 }
740
741 if ksResp.StatusCode != http.StatusNoContent {
742 w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
743 return
744 }
745
746 tx, err := rp.db.BeginTx(r.Context(), nil)
747 if err != nil {
748 log.Println("failed to start tx")
749 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
750 return
751 }
752 defer func() {
753 tx.Rollback()
754 err = rp.enforcer.E.LoadPolicy()
755 if err != nil {
756 log.Println("failed to rollback policies")
757 }
758 }()
759
760 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
761 if err != nil {
762 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
763 return
764 }
765
766 err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
767 if err != nil {
768 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
769 return
770 }
771
772 err = tx.Commit()
773 if err != nil {
774 log.Println("failed to commit changes", err)
775 http.Error(w, err.Error(), http.StatusInternalServerError)
776 return
777 }
778
779 err = rp.enforcer.E.SavePolicy()
780 if err != nil {
781 log.Println("failed to update ACLs", err)
782 http.Error(w, err.Error(), http.StatusInternalServerError)
783 return
784 }
785
786 w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
787
788}
789
790func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
791 user := rp.oauth.GetUser(r)
792
793 f, err := rp.repoResolver.Resolve(r)
794 if err != nil {
795 log.Println("failed to get repo and knot", err)
796 return
797 }
798
799 // remove record from pds
800 xrpcClient, err := rp.oauth.AuthorizedClient(r)
801 if err != nil {
802 log.Println("failed to get authorized client", err)
803 return
804 }
805 repoRkey := f.RepoAt.RecordKey().String()
806 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
807 Collection: tangled.RepoNSID,
808 Repo: user.Did,
809 Rkey: repoRkey,
810 })
811 if err != nil {
812 log.Printf("failed to delete record: %s", err)
813 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
814 return
815 }
816 log.Println("removed repo record ", f.RepoAt.String())
817
818 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
819 if err != nil {
820 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
821 return
822 }
823
824 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
825 if err != nil {
826 log.Println("failed to create client to ", f.Knot)
827 return
828 }
829
830 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
831 if err != nil {
832 log.Printf("failed to make request to %s: %s", f.Knot, err)
833 return
834 }
835
836 if ksResp.StatusCode != http.StatusNoContent {
837 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
838 } else {
839 log.Println("removed repo from knot ", f.Knot)
840 }
841
842 tx, err := rp.db.BeginTx(r.Context(), nil)
843 if err != nil {
844 log.Println("failed to start tx")
845 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
846 return
847 }
848 defer func() {
849 tx.Rollback()
850 err = rp.enforcer.E.LoadPolicy()
851 if err != nil {
852 log.Println("failed to rollback policies")
853 }
854 }()
855
856 // remove collaborator RBAC
857 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
858 if err != nil {
859 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
860 return
861 }
862 for _, c := range repoCollaborators {
863 did := c[0]
864 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
865 }
866 log.Println("removed collaborators")
867
868 // remove repo RBAC
869 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
870 if err != nil {
871 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
872 return
873 }
874
875 // remove repo from db
876 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
877 if err != nil {
878 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
879 return
880 }
881 log.Println("removed repo from db")
882
883 err = tx.Commit()
884 if err != nil {
885 log.Println("failed to commit changes", err)
886 http.Error(w, err.Error(), http.StatusInternalServerError)
887 return
888 }
889
890 err = rp.enforcer.E.SavePolicy()
891 if err != nil {
892 log.Println("failed to update ACLs", err)
893 http.Error(w, err.Error(), http.StatusInternalServerError)
894 return
895 }
896
897 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
898}
899
900func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
901 f, err := rp.repoResolver.Resolve(r)
902 if err != nil {
903 log.Println("failed to get repo and knot", err)
904 return
905 }
906
907 branch := r.FormValue("branch")
908 if branch == "" {
909 http.Error(w, "malformed form", http.StatusBadRequest)
910 return
911 }
912
913 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
914 if err != nil {
915 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
916 return
917 }
918
919 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
920 if err != nil {
921 log.Println("failed to create client to ", f.Knot)
922 return
923 }
924
925 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
926 if err != nil {
927 log.Printf("failed to make request to %s: %s", f.Knot, err)
928 return
929 }
930
931 if ksResp.StatusCode != http.StatusNoContent {
932 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
933 return
934 }
935
936 w.Write(fmt.Append(nil, "default branch set to: ", branch))
937}
938
939func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
940 f, err := rp.repoResolver.Resolve(r)
941 if err != nil {
942 log.Println("failed to get repo and knot", err)
943 return
944 }
945
946 switch r.Method {
947 case http.MethodGet:
948 // for now, this is just pubkeys
949 user := rp.oauth.GetUser(r)
950 repoCollaborators, err := f.Collaborators(r.Context())
951 if err != nil {
952 log.Println("failed to get collaborators", err)
953 }
954
955 isCollaboratorInviteAllowed := false
956 if user != nil {
957 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
958 if err == nil && ok {
959 isCollaboratorInviteAllowed = true
960 }
961 }
962
963 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
964 if err != nil {
965 log.Println("failed to create unsigned client", err)
966 return
967 }
968
969 result, err := us.Branches(f.OwnerDid(), f.RepoName)
970 if err != nil {
971 log.Println("failed to reach knotserver", err)
972 return
973 }
974
975 // all spindles that this user is a member of
976 spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
977 if err != nil {
978 log.Println("failed to fetch spindles", err)
979 return
980 }
981
982 rp.pages.RepoSettings(w, pages.RepoSettingsParams{
983 LoggedInUser: user,
984 RepoInfo: f.RepoInfo(user),
985 Collaborators: repoCollaborators,
986 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
987 Branches: result.Branches,
988 Spindles: spindles,
989 CurrentSpindle: f.Spindle,
990 })
991 }
992}
993
994func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
995 user := rp.oauth.GetUser(r)
996 f, err := rp.repoResolver.Resolve(r)
997 if err != nil {
998 log.Printf("failed to resolve source repo: %v", err)
999 return
1000 }
1001
1002 switch r.Method {
1003 case http.MethodPost:
1004 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1005 if err != nil {
1006 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1007 return
1008 }
1009
1010 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1011 if err != nil {
1012 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1013 return
1014 }
1015
1016 var uri string
1017 if rp.config.Core.Dev {
1018 uri = "http"
1019 } else {
1020 uri = "https"
1021 }
1022 forkName := fmt.Sprintf("%s", f.RepoName)
1023 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1024
1025 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1026 if err != nil {
1027 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1028 return
1029 }
1030
1031 rp.pages.HxRefresh(w)
1032 return
1033 }
1034}
1035
1036func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1037 user := rp.oauth.GetUser(r)
1038 f, err := rp.repoResolver.Resolve(r)
1039 if err != nil {
1040 log.Printf("failed to resolve source repo: %v", err)
1041 return
1042 }
1043
1044 switch r.Method {
1045 case http.MethodGet:
1046 user := rp.oauth.GetUser(r)
1047 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1048 if err != nil {
1049 rp.pages.Notice(w, "repo", "Invalid user account.")
1050 return
1051 }
1052
1053 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1054 LoggedInUser: user,
1055 Knots: knots,
1056 RepoInfo: f.RepoInfo(user),
1057 })
1058
1059 case http.MethodPost:
1060
1061 knot := r.FormValue("knot")
1062 if knot == "" {
1063 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1064 return
1065 }
1066
1067 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1068 if err != nil || !ok {
1069 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1070 return
1071 }
1072
1073 forkName := fmt.Sprintf("%s", f.RepoName)
1074
1075 // this check is *only* to see if the forked repo name already exists
1076 // in the user's account.
1077 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1078 if err != nil {
1079 if errors.Is(err, sql.ErrNoRows) {
1080 // no existing repo with this name found, we can use the name as is
1081 } else {
1082 log.Println("error fetching existing repo from db", err)
1083 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1084 return
1085 }
1086 } else if existingRepo != nil {
1087 // repo with this name already exists, append random string
1088 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1089 }
1090 secret, err := db.GetRegistrationKey(rp.db, knot)
1091 if err != nil {
1092 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1093 return
1094 }
1095
1096 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1097 if err != nil {
1098 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1099 return
1100 }
1101
1102 var uri string
1103 if rp.config.Core.Dev {
1104 uri = "http"
1105 } else {
1106 uri = "https"
1107 }
1108 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1109 sourceAt := f.RepoAt.String()
1110
1111 rkey := appview.TID()
1112 repo := &db.Repo{
1113 Did: user.Did,
1114 Name: forkName,
1115 Knot: knot,
1116 Rkey: rkey,
1117 Source: sourceAt,
1118 }
1119
1120 tx, err := rp.db.BeginTx(r.Context(), nil)
1121 if err != nil {
1122 log.Println(err)
1123 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1124 return
1125 }
1126 defer func() {
1127 tx.Rollback()
1128 err = rp.enforcer.E.LoadPolicy()
1129 if err != nil {
1130 log.Println("failed to rollback policies")
1131 }
1132 }()
1133
1134 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1135 if err != nil {
1136 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1137 return
1138 }
1139
1140 switch resp.StatusCode {
1141 case http.StatusConflict:
1142 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1143 return
1144 case http.StatusInternalServerError:
1145 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1146 case http.StatusNoContent:
1147 // continue
1148 }
1149
1150 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1151 if err != nil {
1152 log.Println("failed to get authorized client", err)
1153 rp.pages.Notice(w, "repo", "Failed to create repository.")
1154 return
1155 }
1156
1157 createdAt := time.Now().Format(time.RFC3339)
1158 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1159 Collection: tangled.RepoNSID,
1160 Repo: user.Did,
1161 Rkey: rkey,
1162 Record: &lexutil.LexiconTypeDecoder{
1163 Val: &tangled.Repo{
1164 Knot: repo.Knot,
1165 Name: repo.Name,
1166 CreatedAt: createdAt,
1167 Owner: user.Did,
1168 Source: &sourceAt,
1169 }},
1170 })
1171 if err != nil {
1172 log.Printf("failed to create record: %s", err)
1173 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1174 return
1175 }
1176 log.Println("created repo record: ", atresp.Uri)
1177
1178 repo.AtUri = atresp.Uri
1179 err = db.AddRepo(tx, repo)
1180 if err != nil {
1181 log.Println(err)
1182 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1183 return
1184 }
1185
1186 // acls
1187 p, _ := securejoin.SecureJoin(user.Did, forkName)
1188 err = rp.enforcer.AddRepo(user.Did, knot, p)
1189 if err != nil {
1190 log.Println(err)
1191 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1192 return
1193 }
1194
1195 err = tx.Commit()
1196 if err != nil {
1197 log.Println("failed to commit changes", err)
1198 http.Error(w, err.Error(), http.StatusInternalServerError)
1199 return
1200 }
1201
1202 err = rp.enforcer.E.SavePolicy()
1203 if err != nil {
1204 log.Println("failed to update ACLs", err)
1205 http.Error(w, err.Error(), http.StatusInternalServerError)
1206 return
1207 }
1208
1209 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1210 return
1211 }
1212}
1213
1214func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1215 user := rp.oauth.GetUser(r)
1216 f, err := rp.repoResolver.Resolve(r)
1217 if err != nil {
1218 log.Println("failed to get repo and knot", err)
1219 return
1220 }
1221
1222 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1223 if err != nil {
1224 log.Printf("failed to create unsigned client for %s", f.Knot)
1225 rp.pages.Error503(w)
1226 return
1227 }
1228
1229 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1230 if err != nil {
1231 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1232 log.Println("failed to reach knotserver", err)
1233 return
1234 }
1235 branches := result.Branches
1236 sort.Slice(branches, func(i int, j int) bool {
1237 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1238 })
1239
1240 var defaultBranch string
1241 for _, b := range branches {
1242 if b.IsDefault {
1243 defaultBranch = b.Name
1244 }
1245 }
1246
1247 base := defaultBranch
1248 head := defaultBranch
1249
1250 params := r.URL.Query()
1251 queryBase := params.Get("base")
1252 queryHead := params.Get("head")
1253 if queryBase != "" {
1254 base = queryBase
1255 }
1256 if queryHead != "" {
1257 head = queryHead
1258 }
1259
1260 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1261 if err != nil {
1262 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1263 log.Println("failed to reach knotserver", err)
1264 return
1265 }
1266
1267 repoinfo := f.RepoInfo(user)
1268
1269 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1270 LoggedInUser: user,
1271 RepoInfo: repoinfo,
1272 Branches: branches,
1273 Tags: tags.Tags,
1274 Base: base,
1275 Head: head,
1276 })
1277}
1278
1279func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1280 user := rp.oauth.GetUser(r)
1281 f, err := rp.repoResolver.Resolve(r)
1282 if err != nil {
1283 log.Println("failed to get repo and knot", err)
1284 return
1285 }
1286
1287 var diffOpts types.DiffOpts
1288 if d := r.URL.Query().Get("diff"); d == "split" {
1289 diffOpts.Split = true
1290 }
1291
1292 // if user is navigating to one of
1293 // /compare/{base}/{head}
1294 // /compare/{base}...{head}
1295 base := chi.URLParam(r, "base")
1296 head := chi.URLParam(r, "head")
1297 if base == "" && head == "" {
1298 rest := chi.URLParam(r, "*") // master...feature/xyz
1299 parts := strings.SplitN(rest, "...", 2)
1300 if len(parts) == 2 {
1301 base = parts[0]
1302 head = parts[1]
1303 }
1304 }
1305
1306 base, _ = url.PathUnescape(base)
1307 head, _ = url.PathUnescape(head)
1308
1309 if base == "" || head == "" {
1310 log.Printf("invalid comparison")
1311 rp.pages.Error404(w)
1312 return
1313 }
1314
1315 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1316 if err != nil {
1317 log.Printf("failed to create unsigned client for %s", f.Knot)
1318 rp.pages.Error503(w)
1319 return
1320 }
1321
1322 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1323 if err != nil {
1324 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1325 log.Println("failed to reach knotserver", err)
1326 return
1327 }
1328
1329 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1330 if err != nil {
1331 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1332 log.Println("failed to reach knotserver", err)
1333 return
1334 }
1335
1336 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1337 if err != nil {
1338 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1339 log.Println("failed to compare", err)
1340 return
1341 }
1342 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1343
1344 repoinfo := f.RepoInfo(user)
1345
1346 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1347 LoggedInUser: user,
1348 RepoInfo: repoinfo,
1349 Branches: branches.Branches,
1350 Tags: tags.Tags,
1351 Base: base,
1352 Head: head,
1353 Diff: &diff,
1354 DiffOpts: diffOpts,
1355 })
1356
1357}