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