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