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