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