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