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