forked from
tangled.org/core
Monorepo for Tangled
40 kB
1528 lines
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "slices"
12 "strings"
13 "time"
14
15 "tangled.org/core/appview/cloudflare"
16
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/config"
19 "tangled.org/core/appview/db"
20 "tangled.org/core/appview/models"
21 "tangled.org/core/appview/notify"
22 "tangled.org/core/appview/oauth"
23 "tangled.org/core/appview/pages"
24 "tangled.org/core/appview/pagination"
25 "tangled.org/core/appview/reporesolver"
26 "tangled.org/core/appview/sites"
27 "tangled.org/core/appview/validator"
28 xrpcclient "tangled.org/core/appview/xrpcclient"
29 "tangled.org/core/eventconsumer"
30 "tangled.org/core/idresolver"
31 "tangled.org/core/ogre"
32 "tangled.org/core/orm"
33 "tangled.org/core/rbac"
34 "tangled.org/core/tid"
35 "tangled.org/core/xrpc/serviceauth"
36
37 comatproto "github.com/bluesky-social/indigo/api/atproto"
38 "github.com/bluesky-social/indigo/atproto/atclient"
39 "github.com/bluesky-social/indigo/atproto/syntax"
40 lexutil "github.com/bluesky-social/indigo/lex/util"
41
42 "github.com/go-chi/chi/v5"
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 notifier notify.Notifier
55 logger *slog.Logger
56 serviceAuth *serviceauth.ServiceAuth
57 validator *validator.Validator
58 cfClient *cloudflare.Client
59 ogreClient *ogre.Client
60}
61
62func New(
63 oauth *oauth.OAuth,
64 repoResolver *reporesolver.RepoResolver,
65 pages *pages.Pages,
66 spindlestream *eventconsumer.Consumer,
67 idResolver *idresolver.Resolver,
68 db *db.DB,
69 config *config.Config,
70 notifier notify.Notifier,
71 enforcer *rbac.Enforcer,
72 logger *slog.Logger,
73 validator *validator.Validator,
74 cfClient *cloudflare.Client,
75) *Repo {
76 return &Repo{
77 oauth: oauth,
78 repoResolver: repoResolver,
79 pages: pages,
80 idResolver: idResolver,
81 config: config,
82 spindlestream: spindlestream,
83 db: db,
84 notifier: notifier,
85 enforcer: enforcer,
86 logger: logger,
87 validator: validator,
88 cfClient: cfClient,
89 ogreClient: ogre.NewClient(config.Ogre.Host),
90 }
91}
92
93// modify the spindle configured for this repo
94func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
95 user := rp.oauth.GetMultiAccountUser(r)
96 l := rp.logger.With("handler", "EditSpindle")
97 l = l.With("did", user.Did)
98
99 errorId := "operation-error"
100 fail := func(msg string, err error) {
101 l.Error(msg, "err", err)
102 rp.pages.Notice(w, errorId, msg)
103 }
104
105 f, err := rp.repoResolver.Resolve(r)
106 if err != nil {
107 fail("Failed to resolve repo. Try again later", err)
108 return
109 }
110
111 newSpindle := r.FormValue("spindle")
112 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
113 client, err := rp.oauth.AuthorizedClient(r)
114 if err != nil {
115 fail("Failed to authorize. Try again later.", err)
116 return
117 }
118
119 if !removingSpindle {
120 // ensure that this is a valid spindle for this user
121 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
122 if err != nil {
123 fail("Failed to find spindles. Try again later.", err)
124 return
125 }
126
127 if !slices.Contains(validSpindles, newSpindle) {
128 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
129 return
130 }
131 }
132
133 newRepo := *f
134 newRepo.Spindle = newSpindle
135 record := newRepo.AsRecord()
136
137 spindlePtr := &newSpindle
138 if removingSpindle {
139 spindlePtr = nil
140 newRepo.Spindle = ""
141 }
142
143 // optimistic update
144 err = db.UpdateSpindle(rp.db, newRepo.RepoDid, spindlePtr)
145 if err != nil {
146 fail("Failed to update spindle. Try again later.", err)
147 return
148 }
149
150 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
151 if err != nil {
152 fail("Failed to update spindle, no record found on PDS.", err)
153 return
154 }
155 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
156 Collection: tangled.RepoNSID,
157 Repo: newRepo.Did,
158 Rkey: newRepo.Rkey,
159 SwapRecord: ex.Cid,
160 Record: &lexutil.LexiconTypeDecoder{
161 Val: &record,
162 },
163 })
164
165 if err != nil {
166 fail("Failed to update spindle, unable to save to PDS.", err)
167 return
168 }
169
170 if !removingSpindle {
171 // add this spindle to spindle stream
172 rp.spindlestream.AddSource(
173 context.Background(),
174 eventconsumer.NewSpindleSource(newSpindle),
175 )
176 }
177
178 rp.pages.HxRefresh(w)
179}
180
181func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
182 user := rp.oauth.GetMultiAccountUser(r)
183 l := rp.logger.With("handler", "AddLabel")
184 l = l.With("did", user.Did)
185
186 f, err := rp.repoResolver.Resolve(r)
187 if err != nil {
188 l.Error("failed to get repo and knot", "err", err)
189 return
190 }
191
192 errorId := "add-label-error"
193 fail := func(msg string, err error) {
194 l.Error(msg, "err", err)
195 rp.pages.Notice(w, errorId, msg)
196 }
197
198 // get form values for label definition
199 name := r.FormValue("name")
200 concreteType := r.FormValue("valueType")
201 valueFormat := r.FormValue("valueFormat")
202 enumValues := r.FormValue("enumValues")
203 scope := r.Form["scope"]
204 color := r.FormValue("color")
205 multiple := r.FormValue("multiple") == "true"
206
207 var variants []string
208 for part := range strings.SplitSeq(enumValues, ",") {
209 if part = strings.TrimSpace(part); part != "" {
210 variants = append(variants, part)
211 }
212 }
213
214 if concreteType == "" {
215 concreteType = "null"
216 }
217
218 format := models.ValueTypeFormatAny
219 if valueFormat == "did" {
220 format = models.ValueTypeFormatDid
221 }
222
223 valueType := models.ValueType{
224 Type: models.ConcreteType(concreteType),
225 Format: format,
226 Enum: variants,
227 }
228
229 label := models.LabelDefinition{
230 Did: user.Did,
231 Rkey: tid.TID(),
232 Name: name,
233 ValueType: valueType,
234 Scope: scope,
235 Color: &color,
236 Multiple: multiple,
237 Created: time.Now(),
238 }
239 if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
240 fail(err.Error(), err)
241 return
242 }
243
244 // announce this relation into the firehose, store into owners' pds
245 client, err := rp.oauth.AuthorizedClient(r)
246 if err != nil {
247 fail(err.Error(), err)
248 return
249 }
250
251 // emit a labelRecord
252 labelRecord := label.AsRecord()
253 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
254 Collection: tangled.LabelDefinitionNSID,
255 Repo: label.Did,
256 Rkey: label.Rkey,
257 Record: &lexutil.LexiconTypeDecoder{
258 Val: &labelRecord,
259 },
260 })
261 // invalid record
262 if err != nil {
263 fail("Failed to write record to PDS.", err)
264 return
265 }
266
267 aturi := resp.Uri
268 l = l.With("at-uri", aturi)
269 l.Info("wrote label record to PDS")
270
271 // update the repo to subscribe to this label
272 newRepo := *f
273 newRepo.Labels = append(newRepo.Labels, aturi)
274 repoRecord := newRepo.AsRecord()
275
276 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
277 if err != nil {
278 fail("Failed to update labels, no record found on PDS.", err)
279 return
280 }
281 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
282 Collection: tangled.RepoNSID,
283 Repo: newRepo.Did,
284 Rkey: newRepo.Rkey,
285 SwapRecord: ex.Cid,
286 Record: &lexutil.LexiconTypeDecoder{
287 Val: &repoRecord,
288 },
289 })
290 if err != nil {
291 fail("Failed to update labels for repo.", err)
292 return
293 }
294
295 tx, err := rp.db.BeginTx(r.Context(), nil)
296 if err != nil {
297 fail("Failed to add label.", err)
298 return
299 }
300
301 rollback := func() {
302 err1 := tx.Rollback()
303 err2 := rollbackRecord(context.Background(), aturi, client)
304
305 // ignore txn complete errors, this is okay
306 if errors.Is(err1, sql.ErrTxDone) {
307 err1 = nil
308 }
309
310 if errs := errors.Join(err1, err2); errs != nil {
311 l.Error("failed to rollback changes", "errs", errs)
312 return
313 }
314 }
315 defer rollback()
316
317 _, err = db.AddLabelDefinition(tx, &label)
318 if err != nil {
319 fail("Failed to add label.", err)
320 return
321 }
322
323 if err = db.SubscribeLabel(tx, &models.RepoLabel{
324 RepoDid: syntax.DID(f.RepoDid),
325 LabelAt: label.AtUri(),
326 }); err != nil {
327 fail("Failed to subscribe to label.", err)
328 return
329 }
330
331 err = tx.Commit()
332 if err != nil {
333 fail("Failed to add label.", err)
334 return
335 }
336
337 // clear aturi when everything is successful
338 aturi = ""
339
340 rp.pages.HxRefresh(w)
341}
342
343func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
344 user := rp.oauth.GetMultiAccountUser(r)
345 l := rp.logger.With("handler", "DeleteLabel")
346 l = l.With("did", user.Did)
347
348 f, err := rp.repoResolver.Resolve(r)
349 if err != nil {
350 l.Error("failed to get repo and knot", "err", err)
351 return
352 }
353
354 errorId := "label-operation"
355 fail := func(msg string, err error) {
356 l.Error(msg, "err", err)
357 rp.pages.Notice(w, errorId, msg)
358 }
359
360 // get form values
361 labelId := r.FormValue("label-id")
362
363 label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId))
364 if err != nil {
365 fail("Failed to find label definition.", err)
366 return
367 }
368
369 client, err := rp.oauth.AuthorizedClient(r)
370 if err != nil {
371 fail(err.Error(), err)
372 return
373 }
374
375 // delete label record from PDS
376 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
377 Collection: tangled.LabelDefinitionNSID,
378 Repo: label.Did,
379 Rkey: label.Rkey,
380 })
381 if err != nil {
382 fail("Failed to delete label record from PDS.", err)
383 return
384 }
385
386 // update repo record to remove the label reference
387 newRepo := *f
388 var updated []string
389 removedAt := label.AtUri().String()
390 for _, l := range newRepo.Labels {
391 if l != removedAt {
392 updated = append(updated, l)
393 }
394 }
395 newRepo.Labels = updated
396 repoRecord := newRepo.AsRecord()
397
398 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
399 if err != nil {
400 fail("Failed to update labels, no record found on PDS.", err)
401 return
402 }
403 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
404 Collection: tangled.RepoNSID,
405 Repo: newRepo.Did,
406 Rkey: newRepo.Rkey,
407 SwapRecord: ex.Cid,
408 Record: &lexutil.LexiconTypeDecoder{
409 Val: &repoRecord,
410 },
411 })
412 if err != nil {
413 fail("Failed to update repo record.", err)
414 return
415 }
416
417 // transaction for DB changes
418 tx, err := rp.db.BeginTx(r.Context(), nil)
419 if err != nil {
420 fail("Failed to delete label.", err)
421 return
422 }
423 defer tx.Rollback()
424
425 err = db.UnsubscribeLabel(
426 tx,
427 orm.FilterEq("repo_did", f.RepoDid),
428 orm.FilterEq("label_at", removedAt),
429 )
430 if err != nil {
431 fail("Failed to unsubscribe label.", err)
432 return
433 }
434
435 err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id))
436 if err != nil {
437 fail("Failed to delete label definition.", err)
438 return
439 }
440
441 err = tx.Commit()
442 if err != nil {
443 fail("Failed to delete label.", err)
444 return
445 }
446
447 // everything succeeded
448 rp.pages.HxRefresh(w)
449}
450
451func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
452 user := rp.oauth.GetMultiAccountUser(r)
453 l := rp.logger.With("handler", "SubscribeLabel")
454 l = l.With("did", user.Did)
455
456 f, err := rp.repoResolver.Resolve(r)
457 if err != nil {
458 l.Error("failed to get repo and knot", "err", err)
459 return
460 }
461
462 if err := r.ParseForm(); err != nil {
463 l.Error("invalid form", "err", err)
464 return
465 }
466
467 errorId := "default-label-operation"
468 fail := func(msg string, err error) {
469 l.Error(msg, "err", err)
470 rp.pages.Notice(w, errorId, msg)
471 }
472
473 labelAts := r.Form["label"]
474 _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
475 if err != nil {
476 fail("Failed to subscribe to label.", err)
477 return
478 }
479
480 newRepo := *f
481 newRepo.Labels = append(newRepo.Labels, labelAts...)
482
483 // dedup
484 slices.Sort(newRepo.Labels)
485 newRepo.Labels = slices.Compact(newRepo.Labels)
486
487 repoRecord := newRepo.AsRecord()
488
489 client, err := rp.oauth.AuthorizedClient(r)
490 if err != nil {
491 fail(err.Error(), err)
492 return
493 }
494
495 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
496 if err != nil {
497 fail("Failed to update labels, no record found on PDS.", err)
498 return
499 }
500 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
501 Collection: tangled.RepoNSID,
502 Repo: newRepo.Did,
503 Rkey: newRepo.Rkey,
504 SwapRecord: ex.Cid,
505 Record: &lexutil.LexiconTypeDecoder{
506 Val: &repoRecord,
507 },
508 })
509
510 tx, err := rp.db.Begin()
511 if err != nil {
512 fail("Failed to subscribe to label.", err)
513 return
514 }
515 defer tx.Rollback()
516
517 for _, l := range labelAts {
518 err = db.SubscribeLabel(tx, &models.RepoLabel{
519 RepoDid: syntax.DID(f.RepoDid),
520 LabelAt: syntax.ATURI(l),
521 })
522 if err != nil {
523 fail("Failed to subscribe to label.", err)
524 return
525 }
526 }
527
528 if err := tx.Commit(); err != nil {
529 fail("Failed to subscribe to label.", err)
530 return
531 }
532
533 // everything succeeded
534 rp.pages.HxRefresh(w)
535}
536
537func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
538 user := rp.oauth.GetMultiAccountUser(r)
539 l := rp.logger.With("handler", "UnsubscribeLabel")
540 l = l.With("did", user.Did)
541
542 f, err := rp.repoResolver.Resolve(r)
543 if err != nil {
544 l.Error("failed to get repo and knot", "err", err)
545 return
546 }
547
548 if err := r.ParseForm(); err != nil {
549 l.Error("invalid form", "err", err)
550 return
551 }
552
553 errorId := "default-label-operation"
554 fail := func(msg string, err error) {
555 l.Error(msg, "err", err)
556 rp.pages.Notice(w, errorId, msg)
557 }
558
559 labelAts := r.Form["label"]
560 _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
561 if err != nil {
562 fail("Failed to unsubscribe to label.", err)
563 return
564 }
565
566 // update repo record to remove the label reference
567 newRepo := *f
568 var updated []string
569 for _, l := range newRepo.Labels {
570 if !slices.Contains(labelAts, l) {
571 updated = append(updated, l)
572 }
573 }
574 newRepo.Labels = updated
575 repoRecord := newRepo.AsRecord()
576
577 client, err := rp.oauth.AuthorizedClient(r)
578 if err != nil {
579 fail(err.Error(), err)
580 return
581 }
582
583 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
584 if err != nil {
585 fail("Failed to update labels, no record found on PDS.", err)
586 return
587 }
588 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
589 Collection: tangled.RepoNSID,
590 Repo: newRepo.Did,
591 Rkey: newRepo.Rkey,
592 SwapRecord: ex.Cid,
593 Record: &lexutil.LexiconTypeDecoder{
594 Val: &repoRecord,
595 },
596 })
597
598 err = db.UnsubscribeLabel(
599 rp.db,
600 orm.FilterEq("repo_did", f.RepoDid),
601 orm.FilterIn("label_at", labelAts),
602 )
603 if err != nil {
604 fail("Failed to unsubscribe label.", err)
605 return
606 }
607
608 // everything succeeded
609 rp.pages.HxRefresh(w)
610}
611
612func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
613 l := rp.logger.With("handler", "LabelPanel")
614
615 f, err := rp.repoResolver.Resolve(r)
616 if err != nil {
617 l.Error("failed to get repo and knot", "err", err)
618 return
619 }
620
621 subjectStr := r.FormValue("subject")
622 subject, err := syntax.ParseATURI(subjectStr)
623 if err != nil {
624 l.Error("failed to get repo and knot", "err", err)
625 return
626 }
627
628 labelDefs, err := db.GetLabelDefinitions(
629 rp.db,
630 orm.FilterIn("at_uri", f.Labels),
631 orm.FilterContains("scope", subject.Collection().String()),
632 )
633 if err != nil {
634 l.Error("failed to fetch label defs", "err", err)
635 return
636 }
637
638 defs := make(map[string]*models.LabelDefinition)
639 for _, l := range labelDefs {
640 defs[l.AtUri().String()] = &l
641 }
642
643 states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
644 if err != nil {
645 l.Error("failed to build label state", "err", err)
646 return
647 }
648 state := states[subject]
649
650 user := rp.oauth.GetMultiAccountUser(r)
651 rp.pages.LabelPanel(w, pages.LabelPanelParams{
652 LoggedInUser: user,
653 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
654 Defs: defs,
655 Subject: subject.String(),
656 State: state,
657 })
658}
659
660func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
661 l := rp.logger.With("handler", "EditLabelPanel")
662
663 f, err := rp.repoResolver.Resolve(r)
664 if err != nil {
665 l.Error("failed to get repo and knot", "err", err)
666 return
667 }
668
669 subjectStr := r.FormValue("subject")
670 subject, err := syntax.ParseATURI(subjectStr)
671 if err != nil {
672 l.Error("failed to get repo and knot", "err", err)
673 return
674 }
675
676 labelDefs, err := db.GetLabelDefinitions(
677 rp.db,
678 orm.FilterIn("at_uri", f.Labels),
679 orm.FilterContains("scope", subject.Collection().String()),
680 )
681 if err != nil {
682 l.Error("failed to fetch labels", "err", err)
683 return
684 }
685
686 defs := make(map[string]*models.LabelDefinition)
687 for _, l := range labelDefs {
688 defs[l.AtUri().String()] = &l
689 }
690
691 states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
692 if err != nil {
693 l.Error("failed to build label state", "err", err)
694 return
695 }
696 state := states[subject]
697
698 user := rp.oauth.GetMultiAccountUser(r)
699 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
700 LoggedInUser: user,
701 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
702 Defs: defs,
703 Subject: subject.String(),
704 State: state,
705 })
706}
707
708func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
709 user := rp.oauth.GetMultiAccountUser(r)
710 l := rp.logger.With("handler", "AddCollaborator")
711 l = l.With("did", user.Did)
712
713 f, err := rp.repoResolver.Resolve(r)
714 if err != nil {
715 l.Error("failed to get repo and knot", "err", err)
716 return
717 }
718
719 errorId := "add-collaborator-error"
720 fail := func(msg string, err error) {
721 l.Error(msg, "err", err)
722 rp.pages.Notice(w, errorId, msg)
723 }
724
725 collaborator := r.FormValue("collaborator")
726 if collaborator == "" {
727 fail("Invalid form.", nil)
728 return
729 }
730
731 // remove a single leading `@`, to make @handle work with ResolveIdent
732 collaborator = strings.TrimPrefix(collaborator, "@")
733
734 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
735 if err != nil {
736 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
737 return
738 }
739
740 if collaboratorIdent.DID.String() == user.Did {
741 fail("You seem to be adding yourself as a collaborator.", nil)
742 return
743 }
744 l = l.With("collaborator", collaboratorIdent.Handle)
745 l = l.With("knot", f.Knot)
746
747 // announce this relation into the firehose, store into owners' pds
748 client, err := rp.oauth.AuthorizedClient(r)
749 if err != nil {
750 fail("Failed to write to PDS.", err)
751 return
752 }
753
754 // emit a record
755 currentUser := rp.oauth.GetMultiAccountUser(r)
756 rkey := tid.TID()
757 createdAt := time.Now()
758 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
759 Collection: tangled.RepoCollaboratorNSID,
760 Repo: currentUser.Did,
761 Rkey: rkey,
762 Record: &lexutil.LexiconTypeDecoder{
763 Val: repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt),
764 },
765 })
766 // invalid record
767 if err != nil {
768 fail("Failed to write record to PDS.", err)
769 return
770 }
771
772 aturi := resp.Uri
773 l = l.With("at-uri", aturi)
774 l.Info("wrote record to PDS")
775
776 tx, err := rp.db.BeginTx(r.Context(), nil)
777 if err != nil {
778 fail("Failed to add collaborator.", err)
779 return
780 }
781
782 rollback := func() {
783 err1 := tx.Rollback()
784 err2 := rp.enforcer.E.LoadPolicy()
785 err3 := rollbackRecord(context.Background(), aturi, client)
786
787 // ignore txn complete errors, this is okay
788 if errors.Is(err1, sql.ErrTxDone) {
789 err1 = nil
790 }
791
792 if errs := errors.Join(err1, err2, err3); errs != nil {
793 l.Error("failed to rollback changes", "errs", errs)
794 return
795 }
796 }
797 defer rollback()
798
799 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.RepoIdentifier())
800 if err != nil {
801 fail("Failed to add collaborator permissions.", err)
802 return
803 }
804
805 err = db.AddCollaborator(tx, models.Collaborator{
806 Did: syntax.DID(currentUser.Did),
807 Rkey: rkey,
808 SubjectDid: collaboratorIdent.DID,
809 RepoDid: syntax.DID(f.RepoDid),
810 Created: createdAt,
811 })
812 if err != nil {
813 fail("Failed to add collaborator.", err)
814 return
815 }
816
817 err = tx.Commit()
818 if err != nil {
819 fail("Failed to add collaborator.", err)
820 return
821 }
822
823 err = rp.enforcer.E.SavePolicy()
824 if err != nil {
825 fail("Failed to update collaborator permissions.", err)
826 return
827 }
828
829 // clear aturi to when everything is successful
830 aturi = ""
831
832 rp.pages.HxRefresh(w)
833}
834
835func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) {
836 l := rp.logger.With("handler", "RenameRepo")
837 noticeId := "rename-repo-error"
838
839 user := rp.oauth.GetMultiAccountUser(r)
840 f, err := rp.repoResolver.Resolve(r)
841 if err != nil {
842 l.Error("failed to get repo and knot", "err", err)
843 rp.pages.Notice(w, noticeId, "Failed to load repository.")
844 return
845 }
846 l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name)
847
848 if f.RepoDid == "" {
849 rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.")
850 return
851 }
852
853 newName, err := validateRenameInput(f.Name, f.Rkey, r.FormValue("name"))
854 if err != nil {
855 rp.pages.Notice(w, noticeId, err.Error())
856 return
857 }
858 newRkey := strings.ToLower(newName)
859 l = l.With("newName", newName, "newRkey", newRkey)
860
861 atpClient, err := rp.oauth.AuthorizedClient(r)
862 if err != nil {
863 l.Error("failed to get authorized client", "err", err)
864 rp.pages.Notice(w, noticeId, "Failed to authorize. Try again later.")
865 return
866 }
867
868 newRepo := *f
869 newRepo.Name = newName
870 newRepo.Rkey = newRkey
871 newRepo.Created = time.Now()
872 record := newRepo.AsRecord()
873
874 if newRkey == f.Rkey {
875 ex, err := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, f.Rkey)
876 if err != nil {
877 l.Error("failed to fetch existing record", "err", err)
878 rp.pages.Notice(w, noticeId, "Failed to read repository record from PDS.")
879 return
880 }
881
882 _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
883 Collection: tangled.RepoNSID,
884 Repo: f.Did,
885 Rkey: f.Rkey,
886 SwapRecord: ex.Cid,
887 Record: &lexutil.LexiconTypeDecoder{
888 Val: &record,
889 },
890 })
891 if err != nil {
892 l.Error("failed to update display name on PDS", "err", err)
893 rp.pages.Notice(w, noticeId, "Failed to save display name to PDS.")
894 return
895 }
896 l.Info("updated display name on PDS")
897
898 if err := db.UpdateRepoDisplayName(rp.db, f.Did, f.Rkey, newName); err != nil {
899 l.Error("optimistic display name update failed", "err", err)
900 }
901 } else {
902 ex, getErr := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, newRkey)
903 switch {
904 case getErr != nil:
905 _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{
906 Collection: tangled.RepoNSID,
907 Repo: f.Did,
908 Rkey: &newRkey,
909 Record: &lexutil.LexiconTypeDecoder{Val: &record},
910 })
911 if err != nil {
912 l.Error("failed to write rename to PDS", "err", err)
913 rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.")
914 return
915 }
916 l.Info("wrote rename-create to PDS; old record retained as alias")
917
918 default:
919 existing, ok := ex.Value.Val.(*tangled.Repo)
920 if !ok || existing.RepoDid == nil || *existing.RepoDid != f.RepoDid {
921 rp.pages.Notice(w, noticeId, fmt.Sprintf("You already have a repository named %q.", newRkey))
922 return
923 }
924 _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
925 Collection: tangled.RepoNSID,
926 Repo: f.Did,
927 Rkey: newRkey,
928 SwapRecord: ex.Cid,
929 Record: &lexutil.LexiconTypeDecoder{Val: &record},
930 })
931 if err != nil {
932 l.Error("failed to rewrite rename-back record on PDS", "err", err)
933 rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.")
934 return
935 }
936 l.Info("rewrote rename-back record on PDS over prior alias")
937 }
938
939 tx, err := rp.db.Begin()
940 if err != nil {
941 l.Error("failed to begin rename tx", "err", err)
942 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
943 return
944 }
945 defer tx.Rollback()
946
947 if err := db.RenameRepo(tx, f.Did, f.Rkey, newRkey, newName); err != nil {
948 l.Error("optimistic rename failed", "err", err)
949 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
950 return
951 }
952 if err := db.RecordRepoRename(tx, f.Did, f.Rkey, f.RepoDid); err != nil {
953 l.Error("failed to record rename history", "err", err)
954 }
955 if err := db.DeleteRepoRename(tx, f.Did, newRkey); err != nil {
956 l.Error("failed to clear stale rename hint", "err", err)
957 }
958 if err := tx.Commit(); err != nil {
959 l.Error("failed to commit rename tx", "err", err)
960 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
961 return
962 }
963 }
964
965 oldRepo := *f
966 rp.notifier.RenameRepo(r.Context(), syntax.DID(user.Did), &oldRepo, &newRepo)
967
968 if newRkey != f.Rkey {
969 rp.migrateSiteOnRename(r.Context(), f, newRkey)
970 }
971
972 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid))
973}
974
975func validateRenameInput(currentName, currentRkey, raw string) (string, error) {
976 newName := strings.TrimSpace(raw)
977 if newName == "" {
978 return "", errors.New("Repository name cannot be empty.")
979 }
980 if err := models.ValidateRepoName(newName); err != nil {
981 return "", err
982 }
983 newName = models.StripGitExt(newName)
984 if newName == currentName {
985 if _, tidErr := syntax.ParseTID(currentRkey); tidErr == nil {
986 return newName, nil
987 }
988 return "", errors.New("New name matches the current name.")
989 }
990 return newName, nil
991}
992
993func (rp *Repo) migrateSiteOnRename(ctx context.Context, oldRepo *models.Repo, newRkey string) {
994 l := rp.logger.With("handler", "migrateSiteOnRename", "repo_did", oldRepo.RepoDid)
995
996 siteConfig, err := db.GetRepoSiteConfig(rp.db, oldRepo.RepoDid)
997 if err != nil || siteConfig == nil {
998 return
999 }
1000
1001 if !rp.cfClient.Enabled() {
1002 return
1003 }
1004
1005 ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, oldRepo.Did)
1006
1007 go func() {
1008 bgCtx := context.Background()
1009 oldRkey := oldRepo.Rkey
1010
1011 if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil {
1012 l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err)
1013 }
1014
1015 newRepo := *oldRepo
1016 newRepo.Rkey = newRkey
1017 if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil {
1018 l.Error("sites: redeploy after rename failed", "err", deployErr)
1019 }
1020
1021 if ownerClaim != nil {
1022 if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil {
1023 l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err)
1024 }
1025 if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil {
1026 l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err)
1027 }
1028 }
1029
1030 l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey)
1031 }()
1032}
1033
1034func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1035 user := rp.oauth.GetMultiAccountUser(r)
1036 l := rp.logger.With("handler", "DeleteRepo")
1037
1038 noticeId := "operation-error"
1039 f, err := rp.repoResolver.Resolve(r)
1040 if err != nil {
1041 l.Error("failed to get repo and knot", "err", err)
1042 return
1043 }
1044
1045 // remove record from pds
1046 atpClient, err := rp.oauth.AuthorizedClient(r)
1047 if err != nil {
1048 l.Error("failed to get authorized client", "err", err)
1049 return
1050 }
1051 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1052 Collection: tangled.RepoNSID,
1053 Repo: user.Did,
1054 Rkey: f.Rkey,
1055 })
1056 if err != nil {
1057 l.Error("failed to delete record", "err", err)
1058 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1059 return
1060 }
1061 l.Info("removed repo record", "aturi", f.RepoAt().String())
1062
1063 client, err := rp.oauth.ServiceClient(
1064 r,
1065 oauth.WithService(f.Knot),
1066 oauth.WithLxm(tangled.RepoDeleteNSID),
1067 oauth.WithDev(rp.config.Core.Dev),
1068 )
1069 if err != nil {
1070 l.Error("failed to connect to knot server", "err", err)
1071 return
1072 }
1073
1074 err = tangled.RepoDelete(
1075 r.Context(),
1076 client,
1077 &tangled.RepoDelete_Input{
1078 Did: f.Did,
1079 Name: f.Name,
1080 Rkey: f.Rkey,
1081 },
1082 )
1083 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1084 l.Error("failed to call XRPC repo.delete", "xrpcerr", xrpcerr, "err", err)
1085 rp.pages.Notice(w, noticeId, xrpcerr.Error())
1086 return
1087 }
1088 l.Info("deleted repo from knot")
1089
1090 tx, err := rp.db.BeginTx(r.Context(), nil)
1091 if err != nil {
1092 l.Error("failed to start tx")
1093 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1094 return
1095 }
1096 defer func() {
1097 tx.Rollback()
1098 err = rp.enforcer.E.LoadPolicy()
1099 if err != nil {
1100 l.Error("failed to rollback policies")
1101 }
1102 }()
1103
1104 // remove collaborator RBAC
1105 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.RepoIdentifier(), f.Knot)
1106 if err != nil {
1107 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1108 return
1109 }
1110 for _, c := range repoCollaborators {
1111 did := c[0]
1112 rp.enforcer.RemoveCollaborator(did, f.Knot, f.RepoIdentifier())
1113 }
1114 l.Info("removed collaborators")
1115
1116 // remove repo RBAC
1117 err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.RepoIdentifier())
1118 if err != nil {
1119 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1120 return
1121 }
1122
1123 // remove repo from db
1124 err = db.RemoveRepo(tx, f.Did, f.Rkey)
1125 if err != nil {
1126 rp.pages.Notice(w, noticeId, "Failed to update appview")
1127 return
1128 }
1129 l.Info("removed repo from db")
1130
1131 err = tx.Commit()
1132 if err != nil {
1133 l.Error("failed to commit changes", "err", err)
1134 http.Error(w, err.Error(), http.StatusInternalServerError)
1135 return
1136 }
1137
1138 err = rp.enforcer.E.SavePolicy()
1139 if err != nil {
1140 l.Error("failed to update ACLs", "err", err)
1141 http.Error(w, err.Error(), http.StatusInternalServerError)
1142 return
1143 }
1144
1145 rp.notifier.DeleteRepo(r.Context(), f)
1146 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did))
1147}
1148
1149func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1150 l := rp.logger.With("handler", "SyncRepoFork")
1151
1152 ref := chi.URLParam(r, "ref")
1153 ref, _ = url.PathUnescape(ref)
1154
1155 user := rp.oauth.GetMultiAccountUser(r)
1156 f, err := rp.repoResolver.Resolve(r)
1157 if err != nil {
1158 l.Error("failed to resolve source repo", "err", err)
1159 return
1160 }
1161
1162 switch r.Method {
1163 case http.MethodPost:
1164 client, err := rp.oauth.ServiceClient(
1165 r,
1166 oauth.WithService(f.Knot),
1167 oauth.WithLxm(tangled.RepoForkSyncNSID),
1168 oauth.WithDev(rp.config.Core.Dev),
1169 )
1170 if err != nil {
1171 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1172 return
1173 }
1174
1175 if f.Source == "" {
1176 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1177 return
1178 }
1179
1180 err = tangled.RepoForkSync(
1181 r.Context(),
1182 client,
1183 &tangled.RepoForkSync_Input{
1184 Did: user.Did,
1185 Name: f.Name,
1186 Source: f.Source,
1187 Branch: ref,
1188 },
1189 )
1190 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1191 l.Error("failed to call XRPC repo.forkSync", "xrpcerr", xrpcerr, "err", err)
1192 rp.pages.Notice(w, "repo", err.Error())
1193 return
1194 }
1195
1196 rp.pages.HxRefresh(w)
1197 return
1198 }
1199}
1200
1201func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1202 l := rp.logger.With("handler", "ForkRepo")
1203
1204 user := rp.oauth.GetMultiAccountUser(r)
1205 f, err := rp.repoResolver.Resolve(r)
1206 if err != nil {
1207 l.Error("failed to resolve source repo", "err", err)
1208 return
1209 }
1210
1211 switch r.Method {
1212 case http.MethodGet:
1213 user := rp.oauth.GetMultiAccountUser(r)
1214 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1215 if err != nil {
1216 rp.pages.Notice(w, "repo", "Invalid user account.")
1217 return
1218 }
1219
1220 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1221 LoggedInUser: user,
1222 Knots: knots,
1223 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1224 })
1225
1226 case http.MethodPost:
1227 l := rp.logger.With("handler", "ForkRepo")
1228
1229 targetKnot := r.FormValue("knot")
1230 if targetKnot == "" {
1231 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1232 return
1233 }
1234 l = l.With("targetKnot", targetKnot)
1235
1236 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1237 if err != nil || !ok {
1238 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1239 return
1240 }
1241
1242 // choose a name for a fork
1243 forkName := strings.ToLower(r.FormValue("repo_name"))
1244 if forkName == "" {
1245 rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
1246 return
1247 }
1248
1249 // this check is *only* to see if the forked repo name already exists
1250 // in the user's account.
1251 existingRepo, err := db.GetRepo(
1252 rp.db,
1253 orm.FilterEq("did", user.Did),
1254 orm.FilterEq("name", forkName),
1255 )
1256 if err != nil {
1257 if !errors.Is(err, sql.ErrNoRows) {
1258 l.Error("error fetching existing repo from db", "err", err)
1259 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1260 return
1261 }
1262 } else if existingRepo != nil {
1263 // repo with this name already exists
1264 rp.pages.Notice(w, "repo", "A repository with this name already exists.")
1265 return
1266 }
1267 l = l.With("forkName", forkName)
1268
1269 uri := "https"
1270 if rp.config.Core.Dev {
1271 uri = "http"
1272 }
1273
1274 forkSourceUrl := fmt.Sprintf("%s://%s/%s", uri, f.Knot, f.RepoIdentifier())
1275 l = l.With("cloneUrl", forkSourceUrl)
1276
1277 rkey := strings.ToLower(forkName)
1278
1279 // TODO: this could coordinate better with the knot to receive a clone status
1280 client, err := rp.oauth.ServiceClient(
1281 r,
1282 oauth.WithService(targetKnot),
1283 oauth.WithLxm(tangled.RepoCreateNSID),
1284 oauth.WithDev(rp.config.Core.Dev),
1285 oauth.WithTimeout(time.Second*20),
1286 )
1287 if err != nil {
1288 l.Error("could not create service client", "err", err)
1289 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1290 return
1291 }
1292
1293 forkInput := &tangled.RepoCreate_Input{
1294 Rkey: rkey,
1295 Name: rkey,
1296 Source: &forkSourceUrl,
1297 }
1298 createResp, err := tangled.RepoCreate(
1299 r.Context(),
1300 client,
1301 forkInput,
1302 )
1303 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1304 l.Error("failed to call XRPC repo.create", "xrpcerr", xrpcerr, "err", err)
1305 rp.pages.Notice(w, "repo", xrpcerr.Error())
1306 return
1307 }
1308
1309 var repoDid string
1310 if createResp != nil && createResp.RepoDid != nil {
1311 repoDid = *createResp.RepoDid
1312 }
1313 if repoDid == "" {
1314 l.Error("knot returned empty repo DID for fork")
1315 rp.pages.Notice(w, "repo", "Knot failed to mint a repo DID. The knot may need to be upgraded.")
1316 return
1317 }
1318
1319 forkSource := f.RepoAt().String()
1320 if f.RepoDid != "" {
1321 forkSource = f.RepoDid
1322 }
1323
1324 repo := &models.Repo{
1325 Did: user.Did,
1326 Name: rkey,
1327 Knot: targetKnot,
1328 Rkey: rkey,
1329 Source: forkSource,
1330 Description: f.Description,
1331 Created: time.Now(),
1332 Labels: rp.config.Label.DefaultLabelDefs,
1333 RepoDid: repoDid,
1334 }
1335 record := repo.AsRecord()
1336
1337 cleanupKnot := func() {
1338 go func() {
1339 delays := []time.Duration{0, 2 * time.Second, 5 * time.Second}
1340 for attempt, delay := range delays {
1341 time.Sleep(delay)
1342 deleteClient, dErr := rp.oauth.ServiceClient(
1343 r,
1344 oauth.WithService(targetKnot),
1345 oauth.WithLxm(tangled.RepoDeleteNSID),
1346 oauth.WithDev(rp.config.Core.Dev),
1347 )
1348 if dErr != nil {
1349 l.Error("failed to create delete client for knot cleanup", "attempt", attempt+1, "err", dErr)
1350 continue
1351 }
1352 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1353 if dErr := tangled.RepoDelete(ctx, deleteClient, &tangled.RepoDelete_Input{
1354 Did: user.Did,
1355 Name: forkName,
1356 Rkey: rkey,
1357 }); dErr != nil {
1358 cancel()
1359 l.Error("failed to clean up fork on knot after rollback", "attempt", attempt+1, "err", dErr)
1360 continue
1361 }
1362 cancel()
1363 l.Info("successfully cleaned up fork on knot after rollback", "attempt", attempt+1)
1364 return
1365 }
1366 l.Error("exhausted retries for knot cleanup, fork may be orphaned",
1367 "did", user.Did, "fork", forkName, "knot", targetKnot)
1368 }()
1369 }
1370
1371 atpClient, err := rp.oauth.AuthorizedClient(r)
1372 if err != nil {
1373 l.Error("failed to create xrpcclient", "err", err)
1374 cleanupKnot()
1375 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1376 return
1377 }
1378
1379 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
1380 Collection: tangled.RepoNSID,
1381 Repo: user.Did,
1382 Rkey: rkey,
1383 Record: &lexutil.LexiconTypeDecoder{
1384 Val: &record,
1385 },
1386 })
1387 if err != nil {
1388 l.Error("failed to write to PDS", "err", err)
1389 cleanupKnot()
1390 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1391 return
1392 }
1393
1394 aturi := atresp.Uri
1395 l = l.With("aturi", aturi)
1396 l.Info("wrote to PDS")
1397
1398 tx, err := rp.db.BeginTx(r.Context(), nil)
1399 if err != nil {
1400 l.Info("txn failed", "err", err)
1401 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1402 return
1403 }
1404
1405 rollback := func() {
1406 err1 := tx.Rollback()
1407 err2 := rp.enforcer.E.LoadPolicy()
1408 err3 := rollbackRecord(context.Background(), aturi, atpClient)
1409
1410 if errors.Is(err1, sql.ErrTxDone) {
1411 err1 = nil
1412 }
1413
1414 if errs := errors.Join(err1, err2, err3); errs != nil {
1415 l.Error("failed to rollback changes", "errs", errs)
1416 }
1417
1418 if aturi != "" {
1419 cleanupKnot()
1420 }
1421 }
1422 defer rollback()
1423
1424 err = db.AddRepo(tx, repo)
1425 if err != nil {
1426 l.Error("failed to AddRepo", "err", err)
1427 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1428 return
1429 }
1430
1431 rbacPath := repo.RepoIdentifier()
1432 err = rp.enforcer.AddRepo(user.Did, targetKnot, rbacPath)
1433 if err != nil {
1434 l.Error("failed to add ACLs", "err", err)
1435 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1436 return
1437 }
1438
1439 err = tx.Commit()
1440 if err != nil {
1441 l.Error("failed to commit changes", "err", err)
1442 http.Error(w, err.Error(), http.StatusInternalServerError)
1443 return
1444 }
1445
1446 err = rp.enforcer.E.SavePolicy()
1447 if err != nil {
1448 l.Error("failed to update ACLs", "err", err)
1449 http.Error(w, err.Error(), http.StatusInternalServerError)
1450 return
1451 }
1452
1453 aturi = ""
1454
1455 rp.notifier.NewRepo(r.Context(), repo)
1456 if repoDid != "" {
1457 rp.pages.HxLocation(w, fmt.Sprintf("/%s", repoDid))
1458 } else {
1459 rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
1460 }
1461 }
1462}
1463
1464func (rp *Repo) Stars(w http.ResponseWriter, r *http.Request) {
1465 l := rp.logger.With("handler", "Stars")
1466
1467 user := rp.oauth.GetMultiAccountUser(r)
1468 f, err := rp.repoResolver.Resolve(r)
1469 if err != nil {
1470 l.Error("failed to resolve source repo", "err", err)
1471 return
1472 }
1473
1474 page := pagination.FromContext(r.Context())
1475 if page.Limit > 30 || page.Limit <= 0 {
1476 page.Limit = 30
1477 }
1478
1479 starrers, err := db.GetStars(rp.db, string(f.RepoDid), page)
1480 if err != nil {
1481 l.Error("failed to fetch starrers", "err", err, "repoDid", f.RepoDid)
1482 return
1483 }
1484
1485 totalCount, err := db.GetStarCount(rp.db, models.StarSubjectRepo, string(f.RepoDid))
1486 if err != nil {
1487 l.Error("failed to fetch star count", "err", err, "repoDid", f.RepoDid)
1488 return
1489 }
1490
1491 rp.pages.RepoStars(w, pages.RepoStarsParams{
1492 LoggedInUser: user,
1493 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1494 Starrers: starrers,
1495 Page: page,
1496 TotalCount: totalCount,
1497 })
1498}
1499
1500// this is used to rollback changes made to the PDS
1501//
1502// it is a no-op if the provided ATURI is empty
1503func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error {
1504 if aturi == "" {
1505 return nil
1506 }
1507
1508 parsed := syntax.ATURI(aturi)
1509
1510 collection := parsed.Collection().String()
1511 repo := parsed.Authority().String()
1512 rkey := parsed.RecordKey().String()
1513
1514 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1515 Collection: collection,
1516 Repo: repo,
1517 Rkey: rkey,
1518 })
1519 return err
1520}
1521
1522func repoCollaboratorRecord(f *models.Repo, subject string, createdAt time.Time) *tangled.RepoCollaborator {
1523 return &tangled.RepoCollaborator{
1524 Subject: subject,
1525 CreatedAt: createdAt.Format(time.RFC3339),
1526 Repo: f.RepoDid,
1527 }
1528}