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