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 "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) RepoSettings(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 switch r.Method {
923 case http.MethodGet:
924 // for now, this is just pubkeys
925 user := rp.oauth.GetUser(r)
926 repoCollaborators, err := f.Collaborators(r.Context())
927 if err != nil {
928 log.Println("failed to get collaborators", err)
929 }
930
931 isCollaboratorInviteAllowed := false
932 if user != nil {
933 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
934 if err == nil && ok {
935 isCollaboratorInviteAllowed = true
936 }
937 }
938
939 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
940 if err != nil {
941 log.Println("failed to create unsigned client", err)
942 return
943 }
944
945 result, err := us.Branches(f.OwnerDid(), f.RepoName)
946 if err != nil {
947 log.Println("failed to reach knotserver", err)
948 return
949 }
950
951 // all spindles that this user is a member of
952 spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
953 if err != nil {
954 log.Println("failed to fetch spindles", err)
955 return
956 }
957
958 rp.pages.RepoSettings(w, pages.RepoSettingsParams{
959 LoggedInUser: user,
960 RepoInfo: f.RepoInfo(user),
961 Collaborators: repoCollaborators,
962 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
963 Branches: result.Branches,
964 Spindles: spindles,
965 CurrentSpindle: f.Spindle,
966 })
967 }
968}
969
970func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
971 user := rp.oauth.GetUser(r)
972 f, err := rp.repoResolver.Resolve(r)
973 if err != nil {
974 log.Printf("failed to resolve source repo: %v", err)
975 return
976 }
977
978 switch r.Method {
979 case http.MethodPost:
980 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
981 if err != nil {
982 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
983 return
984 }
985
986 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
987 if err != nil {
988 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
989 return
990 }
991
992 var uri string
993 if rp.config.Core.Dev {
994 uri = "http"
995 } else {
996 uri = "https"
997 }
998 forkName := fmt.Sprintf("%s", f.RepoName)
999 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1000
1001 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1002 if err != nil {
1003 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1004 return
1005 }
1006
1007 rp.pages.HxRefresh(w)
1008 return
1009 }
1010}
1011
1012func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1013 user := rp.oauth.GetUser(r)
1014 f, err := rp.repoResolver.Resolve(r)
1015 if err != nil {
1016 log.Printf("failed to resolve source repo: %v", err)
1017 return
1018 }
1019
1020 switch r.Method {
1021 case http.MethodGet:
1022 user := rp.oauth.GetUser(r)
1023 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1024 if err != nil {
1025 rp.pages.Notice(w, "repo", "Invalid user account.")
1026 return
1027 }
1028
1029 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1030 LoggedInUser: user,
1031 Knots: knots,
1032 RepoInfo: f.RepoInfo(user),
1033 })
1034
1035 case http.MethodPost:
1036
1037 knot := r.FormValue("knot")
1038 if knot == "" {
1039 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1040 return
1041 }
1042
1043 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1044 if err != nil || !ok {
1045 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1046 return
1047 }
1048
1049 forkName := fmt.Sprintf("%s", f.RepoName)
1050
1051 // this check is *only* to see if the forked repo name already exists
1052 // in the user's account.
1053 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1054 if err != nil {
1055 if errors.Is(err, sql.ErrNoRows) {
1056 // no existing repo with this name found, we can use the name as is
1057 } else {
1058 log.Println("error fetching existing repo from db", err)
1059 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1060 return
1061 }
1062 } else if existingRepo != nil {
1063 // repo with this name already exists, append random string
1064 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1065 }
1066 secret, err := db.GetRegistrationKey(rp.db, knot)
1067 if err != nil {
1068 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1069 return
1070 }
1071
1072 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1073 if err != nil {
1074 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1075 return
1076 }
1077
1078 var uri string
1079 if rp.config.Core.Dev {
1080 uri = "http"
1081 } else {
1082 uri = "https"
1083 }
1084 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1085 sourceAt := f.RepoAt.String()
1086
1087 rkey := tid.TID()
1088 repo := &db.Repo{
1089 Did: user.Did,
1090 Name: forkName,
1091 Knot: knot,
1092 Rkey: rkey,
1093 Source: sourceAt,
1094 }
1095
1096 tx, err := rp.db.BeginTx(r.Context(), nil)
1097 if err != nil {
1098 log.Println(err)
1099 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1100 return
1101 }
1102 defer func() {
1103 tx.Rollback()
1104 err = rp.enforcer.E.LoadPolicy()
1105 if err != nil {
1106 log.Println("failed to rollback policies")
1107 }
1108 }()
1109
1110 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1111 if err != nil {
1112 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1113 return
1114 }
1115
1116 switch resp.StatusCode {
1117 case http.StatusConflict:
1118 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1119 return
1120 case http.StatusInternalServerError:
1121 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1122 case http.StatusNoContent:
1123 // continue
1124 }
1125
1126 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1127 if err != nil {
1128 log.Println("failed to get authorized client", err)
1129 rp.pages.Notice(w, "repo", "Failed to create repository.")
1130 return
1131 }
1132
1133 createdAt := time.Now().Format(time.RFC3339)
1134 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1135 Collection: tangled.RepoNSID,
1136 Repo: user.Did,
1137 Rkey: rkey,
1138 Record: &lexutil.LexiconTypeDecoder{
1139 Val: &tangled.Repo{
1140 Knot: repo.Knot,
1141 Name: repo.Name,
1142 CreatedAt: createdAt,
1143 Owner: user.Did,
1144 Source: &sourceAt,
1145 }},
1146 })
1147 if err != nil {
1148 log.Printf("failed to create record: %s", err)
1149 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1150 return
1151 }
1152 log.Println("created repo record: ", atresp.Uri)
1153
1154 repo.AtUri = atresp.Uri
1155 err = db.AddRepo(tx, repo)
1156 if err != nil {
1157 log.Println(err)
1158 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1159 return
1160 }
1161
1162 // acls
1163 p, _ := securejoin.SecureJoin(user.Did, forkName)
1164 err = rp.enforcer.AddRepo(user.Did, knot, p)
1165 if err != nil {
1166 log.Println(err)
1167 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1168 return
1169 }
1170
1171 err = tx.Commit()
1172 if err != nil {
1173 log.Println("failed to commit changes", err)
1174 http.Error(w, err.Error(), http.StatusInternalServerError)
1175 return
1176 }
1177
1178 err = rp.enforcer.E.SavePolicy()
1179 if err != nil {
1180 log.Println("failed to update ACLs", err)
1181 http.Error(w, err.Error(), http.StatusInternalServerError)
1182 return
1183 }
1184
1185 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1186 return
1187 }
1188}
1189
1190func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1191 user := rp.oauth.GetUser(r)
1192 f, err := rp.repoResolver.Resolve(r)
1193 if err != nil {
1194 log.Println("failed to get repo and knot", err)
1195 return
1196 }
1197
1198 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1199 if err != nil {
1200 log.Printf("failed to create unsigned client for %s", f.Knot)
1201 rp.pages.Error503(w)
1202 return
1203 }
1204
1205 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1206 if err != nil {
1207 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1208 log.Println("failed to reach knotserver", err)
1209 return
1210 }
1211 branches := result.Branches
1212
1213 sortBranches(branches)
1214
1215 var defaultBranch string
1216 for _, b := range branches {
1217 if b.IsDefault {
1218 defaultBranch = b.Name
1219 }
1220 }
1221
1222 base := defaultBranch
1223 head := defaultBranch
1224
1225 params := r.URL.Query()
1226 queryBase := params.Get("base")
1227 queryHead := params.Get("head")
1228 if queryBase != "" {
1229 base = queryBase
1230 }
1231 if queryHead != "" {
1232 head = queryHead
1233 }
1234
1235 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1236 if err != nil {
1237 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1238 log.Println("failed to reach knotserver", err)
1239 return
1240 }
1241
1242 repoinfo := f.RepoInfo(user)
1243
1244 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1245 LoggedInUser: user,
1246 RepoInfo: repoinfo,
1247 Branches: branches,
1248 Tags: tags.Tags,
1249 Base: base,
1250 Head: head,
1251 })
1252}
1253
1254func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1255 user := rp.oauth.GetUser(r)
1256 f, err := rp.repoResolver.Resolve(r)
1257 if err != nil {
1258 log.Println("failed to get repo and knot", err)
1259 return
1260 }
1261
1262 var diffOpts types.DiffOpts
1263 if d := r.URL.Query().Get("diff"); d == "split" {
1264 diffOpts.Split = true
1265 }
1266
1267 // if user is navigating to one of
1268 // /compare/{base}/{head}
1269 // /compare/{base}...{head}
1270 base := chi.URLParam(r, "base")
1271 head := chi.URLParam(r, "head")
1272 if base == "" && head == "" {
1273 rest := chi.URLParam(r, "*") // master...feature/xyz
1274 parts := strings.SplitN(rest, "...", 2)
1275 if len(parts) == 2 {
1276 base = parts[0]
1277 head = parts[1]
1278 }
1279 }
1280
1281 base, _ = url.PathUnescape(base)
1282 head, _ = url.PathUnescape(head)
1283
1284 if base == "" || head == "" {
1285 log.Printf("invalid comparison")
1286 rp.pages.Error404(w)
1287 return
1288 }
1289
1290 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1291 if err != nil {
1292 log.Printf("failed to create unsigned client for %s", f.Knot)
1293 rp.pages.Error503(w)
1294 return
1295 }
1296
1297 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1298 if err != nil {
1299 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1300 log.Println("failed to reach knotserver", err)
1301 return
1302 }
1303
1304 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1305 if err != nil {
1306 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1307 log.Println("failed to reach knotserver", err)
1308 return
1309 }
1310
1311 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1312 if err != nil {
1313 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1314 log.Println("failed to compare", err)
1315 return
1316 }
1317 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1318
1319 repoinfo := f.RepoInfo(user)
1320
1321 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1322 LoggedInUser: user,
1323 RepoInfo: repoinfo,
1324 Branches: branches.Branches,
1325 Tags: tags.Tags,
1326 Base: base,
1327 Head: head,
1328 Diff: &diff,
1329 DiffOpts: diffOpts,
1330 })
1331
1332}