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, 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, 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
1015 if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil {
1016 l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err)
1017 }
1018
1019 newRepo := *oldRepo
1020 newRepo.Rkey = newRkey
1021 if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil {
1022 l.Error("sites: redeploy after rename failed", "err", deployErr)
1023 }
1024
1025 if ownerClaim != nil {
1026 if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil {
1027 l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err)
1028 }
1029 if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil {
1030 l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err)
1031 }
1032 }
1033
1034 l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey)
1035 }()
1036}
1037
1038func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1039 user := rp.oauth.GetMultiAccountUser(r)
1040 l := rp.logger.With("handler", "DeleteRepo")
1041
1042 noticeId := "operation-error"
1043 f, err := rp.repoResolver.Resolve(r)
1044 if err != nil {
1045 l.Error("failed to get repo and knot", "err", err)
1046 return
1047 }
1048
1049 // remove record from pds
1050 atpClient, err := rp.oauth.AuthorizedClient(r)
1051 if err != nil {
1052 l.Error("failed to get authorized client", "err", err)
1053 return
1054 }
1055 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1056 Collection: tangled.RepoNSID,
1057 Repo: user.Did,
1058 Rkey: f.Rkey,
1059 })
1060 if err != nil {
1061 l.Error("failed to delete record", "err", err)
1062 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1063 return
1064 }
1065 l.Info("removed repo record", "aturi", f.RepoAt().String())
1066
1067 client, err := rp.oauth.ServiceClient(
1068 r,
1069 oauth.WithService(f.Knot),
1070 oauth.WithLxm(tangled.RepoDeleteNSID),
1071 oauth.WithDev(rp.config.Core.Dev),
1072 )
1073 if err != nil {
1074 l.Error("failed to connect to knot server", "err", err)
1075 return
1076 }
1077
1078 err = tangled.RepoDelete(
1079 r.Context(),
1080 client,
1081 &tangled.RepoDelete_Input{
1082 Did: f.Did,
1083 Name: f.Name,
1084 Rkey: f.Rkey,
1085 },
1086 )
1087 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1088 l.Error("failed to call XRPC repo.delete", "xrpcerr", xrpcerr, "err", err)
1089 rp.pages.Notice(w, noticeId, xrpcerr.Error())
1090 return
1091 }
1092 l.Info("deleted repo from knot")
1093
1094 tx, err := rp.db.BeginTx(r.Context(), nil)
1095 if err != nil {
1096 l.Error("failed to start tx")
1097 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1098 return
1099 }
1100 defer func() {
1101 tx.Rollback()
1102 err = rp.enforcer.E.LoadPolicy()
1103 if err != nil {
1104 l.Error("failed to rollback policies")
1105 }
1106 }()
1107
1108 // remove collaborator RBAC
1109 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.RepoIdentifier(), f.Knot)
1110 if err != nil {
1111 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1112 return
1113 }
1114 for _, c := range repoCollaborators {
1115 did := c[0]
1116 rp.enforcer.RemoveCollaborator(did, f.Knot, f.RepoIdentifier())
1117 }
1118 l.Info("removed collaborators")
1119
1120 // remove repo RBAC
1121 err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.RepoIdentifier())
1122 if err != nil {
1123 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1124 return
1125 }
1126
1127 // remove repo from db
1128 err = db.RemoveRepo(tx, f.Did, f.Rkey)
1129 if err != nil {
1130 rp.pages.Notice(w, noticeId, "Failed to update appview")
1131 return
1132 }
1133 l.Info("removed repo from db")
1134
1135 err = tx.Commit()
1136 if err != nil {
1137 l.Error("failed to commit changes", "err", err)
1138 http.Error(w, err.Error(), http.StatusInternalServerError)
1139 return
1140 }
1141
1142 err = rp.enforcer.E.SavePolicy()
1143 if err != nil {
1144 l.Error("failed to update ACLs", "err", err)
1145 http.Error(w, err.Error(), http.StatusInternalServerError)
1146 return
1147 }
1148
1149 rp.notifier.DeleteRepo(r.Context(), f)
1150 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did))
1151}
1152
1153func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1154 l := rp.logger.With("handler", "SyncRepoFork")
1155
1156 ref := chi.URLParam(r, "ref")
1157 ref, _ = url.PathUnescape(ref)
1158
1159 user := rp.oauth.GetMultiAccountUser(r)
1160 f, err := rp.repoResolver.Resolve(r)
1161 if err != nil {
1162 l.Error("failed to resolve source repo", "err", err)
1163 return
1164 }
1165
1166 switch r.Method {
1167 case http.MethodPost:
1168 client, err := rp.oauth.ServiceClient(
1169 r,
1170 oauth.WithService(f.Knot),
1171 oauth.WithLxm(tangled.RepoForkSyncNSID),
1172 oauth.WithDev(rp.config.Core.Dev),
1173 )
1174 if err != nil {
1175 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1176 return
1177 }
1178
1179 if f.Source == "" {
1180 rp.pages.Notice(w, "repo", "This repository is not a fork.")
1181 return
1182 }
1183
1184 err = tangled.RepoForkSync(
1185 r.Context(),
1186 client,
1187 &tangled.RepoForkSync_Input{
1188 Did: user.Did,
1189 Name: f.Name,
1190 Source: f.Source,
1191 Branch: ref,
1192 },
1193 )
1194 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1195 l.Error("failed to call XRPC repo.forkSync", "xrpcerr", xrpcerr, "err", err)
1196 rp.pages.Notice(w, "repo", err.Error())
1197 return
1198 }
1199
1200 rp.pages.HxRefresh(w)
1201 return
1202 }
1203}
1204
1205func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1206 l := rp.logger.With("handler", "ForkRepo")
1207
1208 user := rp.oauth.GetMultiAccountUser(r)
1209 f, err := rp.repoResolver.Resolve(r)
1210 if err != nil {
1211 l.Error("failed to resolve source repo", "err", err)
1212 return
1213 }
1214
1215 switch r.Method {
1216 case http.MethodGet:
1217 user := rp.oauth.GetMultiAccountUser(r)
1218 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1219 if err != nil {
1220 rp.pages.Notice(w, "repo", "Invalid user account.")
1221 return
1222 }
1223
1224 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1225 LoggedInUser: user,
1226 Knots: knots,
1227 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1228 })
1229
1230 case http.MethodPost:
1231 l := rp.logger.With("handler", "ForkRepo")
1232
1233 targetKnot := r.FormValue("knot")
1234 if targetKnot == "" {
1235 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1236 return
1237 }
1238 l = l.With("targetKnot", targetKnot)
1239
1240 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1241 if err != nil || !ok {
1242 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1243 return
1244 }
1245
1246 // choose a name for a fork
1247 forkName := strings.ToLower(r.FormValue("repo_name"))
1248 if forkName == "" {
1249 rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
1250 return
1251 }
1252
1253 // this check is *only* to see if the forked repo name already exists
1254 // in the user's account.
1255 existingRepo, err := db.GetRepo(
1256 rp.db,
1257 orm.FilterEq("did", user.Did),
1258 orm.FilterEq("name", forkName),
1259 )
1260 if err != nil {
1261 if !errors.Is(err, sql.ErrNoRows) {
1262 l.Error("error fetching existing repo from db", "err", err)
1263 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1264 return
1265 }
1266 } else if existingRepo != nil {
1267 // repo with this name already exists
1268 rp.pages.Notice(w, "repo", "A repository with this name already exists.")
1269 return
1270 }
1271 l = l.With("forkName", forkName)
1272
1273 uri := "https"
1274 if rp.config.Core.Dev {
1275 uri = "http"
1276 }
1277
1278 forkSourceUrl := fmt.Sprintf("%s://%s/%s", uri, f.Knot, f.RepoIdentifier())
1279 l = l.With("cloneUrl", forkSourceUrl)
1280
1281 rkey := strings.ToLower(forkName)
1282
1283 // TODO: this could coordinate better with the knot to receive a clone status
1284 client, err := rp.oauth.ServiceClient(
1285 r,
1286 oauth.WithService(targetKnot),
1287 oauth.WithLxm(tangled.RepoCreateNSID),
1288 oauth.WithDev(rp.config.Core.Dev),
1289 oauth.WithTimeout(time.Second*20),
1290 )
1291 if err != nil {
1292 l.Error("could not create service client", "err", err)
1293 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1294 return
1295 }
1296
1297 forkInput := &tangled.RepoCreate_Input{
1298 Rkey: rkey,
1299 Name: rkey,
1300 Source: &forkSourceUrl,
1301 }
1302 createResp, err := tangled.RepoCreate(
1303 r.Context(),
1304 client,
1305 forkInput,
1306 )
1307 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1308 l.Error("failed to call XRPC repo.create", "xrpcerr", xrpcerr, "err", err)
1309 rp.pages.Notice(w, "repo", xrpcerr.Error())
1310 return
1311 }
1312
1313 var repoDid string
1314 if createResp != nil && createResp.RepoDid != nil {
1315 repoDid = *createResp.RepoDid
1316 }
1317 if repoDid == "" {
1318 l.Error("knot returned empty repo DID for fork")
1319 rp.pages.Notice(w, "repo", "Knot failed to mint a repo DID. The knot may need to be upgraded.")
1320 return
1321 }
1322
1323 forkSource := f.RepoAt().String()
1324 if f.RepoDid != "" {
1325 forkSource = f.RepoDid
1326 }
1327
1328 repo := &models.Repo{
1329 Did: user.Did,
1330 Name: rkey,
1331 Knot: targetKnot,
1332 Rkey: rkey,
1333 Source: forkSource,
1334 Description: f.Description,
1335 Created: time.Now(),
1336 Labels: rp.config.Label.DefaultLabelDefs,
1337 RepoDid: repoDid,
1338 }
1339 record := repo.AsRecord()
1340
1341 cleanupKnot := func() {
1342 go func() {
1343 delays := []time.Duration{0, 2 * time.Second, 5 * time.Second}
1344 for attempt, delay := range delays {
1345 time.Sleep(delay)
1346 deleteClient, dErr := rp.oauth.ServiceClient(
1347 r,
1348 oauth.WithService(targetKnot),
1349 oauth.WithLxm(tangled.RepoDeleteNSID),
1350 oauth.WithDev(rp.config.Core.Dev),
1351 )
1352 if dErr != nil {
1353 l.Error("failed to create delete client for knot cleanup", "attempt", attempt+1, "err", dErr)
1354 continue
1355 }
1356 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1357 if dErr := tangled.RepoDelete(ctx, deleteClient, &tangled.RepoDelete_Input{
1358 Did: user.Did,
1359 Name: forkName,
1360 Rkey: rkey,
1361 }); dErr != nil {
1362 cancel()
1363 l.Error("failed to clean up fork on knot after rollback", "attempt", attempt+1, "err", dErr)
1364 continue
1365 }
1366 cancel()
1367 l.Info("successfully cleaned up fork on knot after rollback", "attempt", attempt+1)
1368 return
1369 }
1370 l.Error("exhausted retries for knot cleanup, fork may be orphaned",
1371 "did", user.Did, "fork", forkName, "knot", targetKnot)
1372 }()
1373 }
1374
1375 atpClient, err := rp.oauth.AuthorizedClient(r)
1376 if err != nil {
1377 l.Error("failed to create xrpcclient", "err", err)
1378 cleanupKnot()
1379 rp.pages.Notice(w, "repo", "Failed to fork repository.")
1380 return
1381 }
1382
1383 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
1384 Collection: tangled.RepoNSID,
1385 Repo: user.Did,
1386 Rkey: rkey,
1387 Record: &lexutil.LexiconTypeDecoder{
1388 Val: &record,
1389 },
1390 })
1391 if err != nil {
1392 l.Error("failed to write to PDS", "err", err)
1393 cleanupKnot()
1394 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1395 return
1396 }
1397
1398 aturi := atresp.Uri
1399 l = l.With("aturi", aturi)
1400 l.Info("wrote to PDS")
1401
1402 tx, err := rp.db.BeginTx(r.Context(), nil)
1403 if err != nil {
1404 l.Info("txn failed", "err", err)
1405 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1406 return
1407 }
1408
1409 rollback := func() {
1410 err1 := tx.Rollback()
1411 err2 := rp.enforcer.E.LoadPolicy()
1412 err3 := rollbackRecord(context.Background(), aturi, atpClient)
1413
1414 if errors.Is(err1, sql.ErrTxDone) {
1415 err1 = nil
1416 }
1417
1418 if errs := errors.Join(err1, err2, err3); errs != nil {
1419 l.Error("failed to rollback changes", "errs", errs)
1420 }
1421
1422 if aturi != "" {
1423 cleanupKnot()
1424 }
1425 }
1426 defer rollback()
1427
1428 err = db.AddRepo(tx, repo)
1429 if err != nil {
1430 l.Error("failed to AddRepo", "err", err)
1431 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1432 return
1433 }
1434
1435 rbacPath := repo.RepoIdentifier()
1436 err = rp.enforcer.AddRepo(user.Did, targetKnot, rbacPath)
1437 if err != nil {
1438 l.Error("failed to add ACLs", "err", err)
1439 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1440 return
1441 }
1442
1443 err = tx.Commit()
1444 if err != nil {
1445 l.Error("failed to commit changes", "err", err)
1446 http.Error(w, err.Error(), http.StatusInternalServerError)
1447 return
1448 }
1449
1450 err = rp.enforcer.E.SavePolicy()
1451 if err != nil {
1452 l.Error("failed to update ACLs", "err", err)
1453 http.Error(w, err.Error(), http.StatusInternalServerError)
1454 return
1455 }
1456
1457 aturi = ""
1458
1459 rp.notifier.NewRepo(r.Context(), repo)
1460 if repoDid != "" {
1461 rp.pages.HxLocation(w, fmt.Sprintf("/%s", repoDid))
1462 } else {
1463 rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
1464 }
1465 }
1466}
1467
1468func (rp *Repo) Stars(w http.ResponseWriter, r *http.Request) {
1469 l := rp.logger.With("handler", "Stars")
1470
1471 user := rp.oauth.GetMultiAccountUser(r)
1472 f, err := rp.repoResolver.Resolve(r)
1473 if err != nil {
1474 l.Error("failed to resolve source repo", "err", err)
1475 return
1476 }
1477
1478 page := pagination.FromContext(r.Context())
1479 if page.Limit > 30 || page.Limit <= 0 {
1480 page.Limit = 30
1481 }
1482
1483 starrers, err := db.GetStars(rp.db, string(f.RepoDid), page)
1484 if err != nil {
1485 l.Error("failed to fetch starrers", "err", err, "repoDid", f.RepoDid)
1486 return
1487 }
1488
1489 totalCount, err := db.GetStarCount(rp.db, models.StarSubjectRepo, string(f.RepoDid))
1490 if err != nil {
1491 l.Error("failed to fetch star count", "err", err, "repoDid", f.RepoDid)
1492 return
1493 }
1494
1495 rp.pages.RepoStars(w, pages.RepoStarsParams{
1496 LoggedInUser: user,
1497 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1498 Starrers: starrers,
1499 Page: page,
1500 TotalCount: totalCount,
1501 })
1502}
1503
1504func (rp *Repo) Forks(w http.ResponseWriter, r *http.Request) {
1505 l := rp.logger.With("handler", "Forks")
1506
1507 user := rp.oauth.GetMultiAccountUser(r)
1508 f, err := rp.repoResolver.Resolve(r)
1509 if err != nil {
1510 l.Error("failed to resolve source repo", "err", err)
1511 return
1512 }
1513
1514 var forks []models.Repo
1515 totalCount := 0
1516 page := pagination.FromContext(r.Context())
1517 if f.RepoDid != "" {
1518 forks, err = db.GetReposPaginated(rp.db, page, orm.FilterEq("source", f.RepoDid))
1519 if err != nil {
1520 l.Error("failed to fetch forks", "err", err, "repoAt", f.RepoAt())
1521 return
1522 }
1523
1524 totalCount, err = db.GetForkCount(rp.db, f.RepoDid)
1525 if err != nil {
1526 l.Error("failed to fetch fork count", "err", err, "repoAt", f.RepoAt())
1527 return
1528 }
1529 }
1530
1531 err = rp.pages.RepoForks(w, pages.RepoForksParams{
1532 LoggedInUser: user,
1533 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1534 Forks: forks,
1535 Page: page,
1536 TotalCount: totalCount,
1537 })
1538 if err != nil {
1539 l.Error("failed to render page", "err", err)
1540 }
1541}
1542
1543// this is used to rollback changes made to the PDS
1544//
1545// it is a no-op if the provided ATURI is empty
1546func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error {
1547 if aturi == "" {
1548 return nil
1549 }
1550
1551 parsed := syntax.ATURI(aturi)
1552
1553 collection := parsed.Collection().String()
1554 repo := parsed.Authority().String()
1555 rkey := parsed.RecordKey().String()
1556
1557 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1558 Collection: collection,
1559 Repo: repo,
1560 Rkey: rkey,
1561 })
1562 return err
1563}
1564
1565func repoCollaboratorRecord(f *models.Repo, subject string, createdAt time.Time) *tangled.RepoCollaborator {
1566 return &tangled.RepoCollaborator{
1567 Subject: subject,
1568 CreatedAt: createdAt.Format(time.RFC3339),
1569 Repo: f.RepoDid,
1570 }
1571}