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