summaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/actions.go190
-rw-r--r--internal/admin/actions_test.go180
-rw-r--r--internal/admin/domainkeys.go51
-rw-r--r--internal/admin/domainperms.go387
-rw-r--r--internal/admin/util.go99
5 files changed, 907 insertions, 0 deletions
diff --git a/internal/admin/actions.go b/internal/admin/actions.go
new file mode 100644
index 000000000..057bfe07d
--- /dev/null
+++ b/internal/admin/actions.go
@@ -0,0 +1,190 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package admin
+
+import (
+ "context"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/workers"
+)
+
+func errActionConflict(action *gtsmodel.AdminAction) gtserror.WithCode {
+ err := gtserror.NewfAt(
+ 4, // Include caller's function name.
+ "an action (%s) is currently running (duration %s) which conflicts with the attempted action",
+ action.Key(), time.Since(action.CreatedAt),
+ )
+
+ const help = "wait until this action is complete and try again"
+ return gtserror.NewErrorConflict(err, err.Error(), help)
+}
+
+type Actions struct {
+ // Map of running actions.
+ running map[string]*gtsmodel.AdminAction
+
+ // Lock for running admin actions.
+ //
+ // Not embedded struct, to shield
+ // from access by outside packages.
+ m sync.Mutex
+
+ // DB for storing, updating,
+ // deleting admin actions etc.
+ db db.DB
+
+ // Workers for queuing
+ // admin action side effects.
+ workers *workers.Workers
+}
+
+func New(db db.DB, workers *workers.Workers) *Actions {
+ return &Actions{
+ running: make(map[string]*gtsmodel.AdminAction),
+ db: db,
+ workers: workers,
+ }
+}
+
+type ActionF func(context.Context) gtserror.MultiError
+
+// Run runs the given admin action by executing the supplied function.
+//
+// Run handles locking, action insertion and updating, so you don't have to!
+//
+// If an action is already running which overlaps/conflicts with the
+// given action, an ErrorWithCode 409 will be returned.
+//
+// If execution of the provided function returns errors, the errors
+// will be updated on the provided admin action in the database.
+func (a *Actions) Run(
+ ctx context.Context,
+ adminAction *gtsmodel.AdminAction,
+ f ActionF,
+) gtserror.WithCode {
+ actionKey := adminAction.Key()
+
+ // LOCK THE MAP HERE, since we're
+ // going to do some operations on it.
+ a.m.Lock()
+
+ // Bail if an action with
+ // this key is already running.
+ running, ok := a.running[actionKey]
+ if ok {
+ a.m.Unlock()
+ return errActionConflict(running)
+ }
+
+ // Action with this key not
+ // yet running, create it.
+ if err := a.db.PutAdminAction(ctx, adminAction); err != nil {
+ err = gtserror.Newf("db error putting admin action %s: %w", actionKey, err)
+
+ // Don't store in map
+ // if there's an error.
+ a.m.Unlock()
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ // Action was inserted,
+ // store in map.
+ a.running[actionKey] = adminAction
+
+ // UNLOCK THE MAP HERE, since
+ // we're done modifying it for now.
+ a.m.Unlock()
+
+ go func() {
+ // Use a background context with existing values.
+ ctx = gtscontext.WithValues(context.Background(), ctx)
+
+ // Run the thing and collect errors.
+ if errs := f(ctx); errs != nil {
+ adminAction.Errors = make([]string, 0, len(errs))
+ for _, err := range errs {
+ adminAction.Errors = append(adminAction.Errors, err.Error())
+ }
+ }
+
+ // Action is no longer running:
+ // remove from running map.
+ a.m.Lock()
+ delete(a.running, actionKey)
+ a.m.Unlock()
+
+ // Mark as completed in the db,
+ // storing errors for later review.
+ adminAction.CompletedAt = time.Now()
+ if err := a.db.UpdateAdminAction(ctx, adminAction, "completed_at", "errors"); err != nil {
+ log.Errorf(ctx, "db error marking action %s as completed: %q", actionKey, err)
+ }
+ }()
+
+ return nil
+}
+
+// GetRunning sounds like a threat, but it actually just
+// returns all of the currently running actions held by
+// the Actions struct, ordered by ID descending.
+func (a *Actions) GetRunning() []*gtsmodel.AdminAction {
+ a.m.Lock()
+ defer a.m.Unlock()
+
+ // Assemble all currently running actions.
+ running := make([]*gtsmodel.AdminAction, 0, len(a.running))
+ for _, action := range a.running {
+ running = append(running, action)
+ }
+
+ // Order by ID descending (creation date).
+ slices.SortFunc(
+ running,
+ func(a *gtsmodel.AdminAction, b *gtsmodel.AdminAction) int {
+ const k = -1
+ switch {
+ case a.ID > b.ID:
+ return +k
+ case a.ID < b.ID:
+ return -k
+ default:
+ return 0
+ }
+ },
+ )
+
+ return running
+}
+
+// TotalRunning is a sequel to the classic
+// 1972 environmental-themed science fiction
+// film Silent Running, starring Bruce Dern.
+func (a *Actions) TotalRunning() int {
+ a.m.Lock()
+ defer a.m.Unlock()
+
+ return len(a.running)
+}
diff --git a/internal/admin/actions_test.go b/internal/admin/actions_test.go
new file mode 100644
index 000000000..c5084d955
--- /dev/null
+++ b/internal/admin/actions_test.go
@@ -0,0 +1,180 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package admin_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+const (
+ rMediaPath = "../../testrig/media"
+ rTemplatePath = "../../web/template"
+)
+
+type ActionsTestSuite struct {
+ suite.Suite
+}
+
+func (suite *ActionsTestSuite) SetupSuite() {
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+}
+
+func (suite *ActionsTestSuite) TestActionOverlap() {
+ var (
+ testStructs = testrig.SetupTestStructs(rMediaPath, rTemplatePath)
+ ctx = context.Background()
+ )
+ defer testrig.TearDownTestStructs(testStructs)
+
+ // Suspend account.
+ action1 := &gtsmodel.AdminAction{
+ ID: id.NewULID(),
+ TargetCategory: gtsmodel.AdminActionCategoryAccount,
+ TargetID: "01H90S1CXQ97J9625C5YBXZWGT",
+ Type: gtsmodel.AdminActionSuspend,
+ AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
+ }
+ key1 := action1.Key()
+ suite.Equal("account/01H90S1CXQ97J9625C5YBXZWGT", key1)
+
+ // Unsuspend account.
+ action2 := &gtsmodel.AdminAction{
+ ID: id.NewULID(),
+ TargetCategory: gtsmodel.AdminActionCategoryAccount,
+ TargetID: "01H90S1CXQ97J9625C5YBXZWGT",
+ Type: gtsmodel.AdminActionUnsuspend,
+ AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
+ }
+ key2 := action2.Key()
+ suite.Equal("account/01H90S1CXQ97J9625C5YBXZWGT", key2)
+
+ errWithCode := testStructs.State.AdminActions.Run(
+ ctx,
+ action1,
+ func(ctx context.Context) gtserror.MultiError {
+ // Noop, just sleep (mood).
+ time.Sleep(3 * time.Second)
+ return nil
+ },
+ )
+ suite.NoError(errWithCode)
+
+ // While first action is sleeping, try to
+ // process another with the same key.
+ errWithCode = testStructs.State.AdminActions.Run(
+ ctx,
+ action2,
+ func(ctx context.Context) gtserror.MultiError {
+ return nil
+ },
+ )
+ if errWithCode == nil {
+ suite.FailNow("expected error with code, but error was nil")
+ }
+
+ // Code should be 409.
+ suite.Equal(http.StatusConflict, errWithCode.Code())
+
+ // Wait for action to finish.
+ if !testrig.WaitFor(func() bool {
+ return testStructs.State.AdminActions.TotalRunning() == 0
+ }) {
+ suite.FailNow("timed out waiting for admin action(s) to finish")
+ }
+
+ // Try again.
+ errWithCode = testStructs.State.AdminActions.Run(
+ ctx,
+ action2,
+ func(ctx context.Context) gtserror.MultiError {
+ return nil
+ },
+ )
+ suite.NoError(errWithCode)
+
+ // Wait for action to finish.
+ if !testrig.WaitFor(func() bool {
+ return testStructs.State.AdminActions.TotalRunning() == 0
+ }) {
+ suite.FailNow("timed out waiting for admin action(s) to finish")
+ }
+}
+
+func (suite *ActionsTestSuite) TestActionWithErrors() {
+ var (
+ testStructs = testrig.SetupTestStructs(rMediaPath, rTemplatePath)
+ ctx = context.Background()
+ )
+ defer testrig.TearDownTestStructs(testStructs)
+
+ // Suspend a domain.
+ action := &gtsmodel.AdminAction{
+ ID: id.NewULID(),
+ TargetCategory: gtsmodel.AdminActionCategoryDomain,
+ TargetID: "example.org",
+ Type: gtsmodel.AdminActionSuspend,
+ AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
+ }
+
+ errWithCode := testStructs.State.AdminActions.Run(
+ ctx,
+ action,
+ func(ctx context.Context) gtserror.MultiError {
+ // Noop, just return some errs.
+ return gtserror.MultiError{
+ db.ErrNoEntries,
+ errors.New("fucky wucky"),
+ }
+ },
+ )
+ suite.NoError(errWithCode)
+
+ // Wait for action to finish.
+ if !testrig.WaitFor(func() bool {
+ return testStructs.State.AdminActions.TotalRunning() == 0
+ }) {
+ suite.FailNow("timed out waiting for admin action(s) to finish")
+ }
+
+ // Get action from the db.
+ dbAction, err := testStructs.State.DB.GetAdminAction(ctx, action.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.EqualValues([]string{
+ "sql: no rows in result set",
+ "fucky wucky",
+ }, dbAction.Errors)
+}
+
+func TestActionsTestSuite(t *testing.T) {
+ suite.Run(t, new(ActionsTestSuite))
+}
diff --git a/internal/admin/domainkeys.go b/internal/admin/domainkeys.go
new file mode 100644
index 000000000..ad943ce80
--- /dev/null
+++ b/internal/admin/domainkeys.go
@@ -0,0 +1,51 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package admin
+
+import (
+ "context"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (a *Actions) DomainKeysExpireF(domain string) ActionF {
+ return func(ctx context.Context) gtserror.MultiError {
+ var (
+ expiresAt = time.Now()
+ errs gtserror.MultiError
+ )
+
+ // For each account on this domain, expire
+ // the public key and update the account.
+ if err := a.rangeDomainAccounts(ctx, domain, func(account *gtsmodel.Account) {
+ account.PublicKeyExpiresAt = expiresAt
+ if err := a.db.UpdateAccount(ctx,
+ account,
+ "public_key_expires_at",
+ ); err != nil {
+ errs.Appendf("db error updating account: %w", err)
+ }
+ }); err != nil {
+ errs.Appendf("db error ranging through accounts: %w", err)
+ }
+
+ return errs
+ }
+}
diff --git a/internal/admin/domainperms.go b/internal/admin/domainperms.go
new file mode 100644
index 000000000..fa686df43
--- /dev/null
+++ b/internal/admin/domainperms.go
@@ -0,0 +1,387 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package admin
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "codeberg.org/gruf/go-kv"
+
+ "github.com/superseriousbusiness/gotosocial/internal/ap"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/messages"
+)
+
+// Returns an AdminActionF for
+// domain allow side effects.
+func (a *Actions) DomainAllowF(
+ actionID string,
+ domainAllow *gtsmodel.DomainAllow,
+) ActionF {
+ return func(ctx context.Context) gtserror.MultiError {
+ l := log.
+ WithContext(ctx).
+ WithFields(kv.Fields{
+ {"action", "allow"},
+ {"actionID", actionID},
+ {"domain", domainAllow.Domain},
+ }...)
+
+ // Log start + finish.
+ l.Info("processing side effects")
+ errs := a.domainAllowSideEffects(ctx, domainAllow)
+ l.Info("finished processing side effects")
+
+ return errs
+ }
+}
+
+func (a *Actions) domainAllowSideEffects(
+ ctx context.Context,
+ allow *gtsmodel.DomainAllow,
+) gtserror.MultiError {
+ if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
+ // We're running in allowlist mode,
+ // so there are no side effects to
+ // process here.
+ return nil
+ }
+
+ // We're running in blocklist mode or
+ // some similar mode which necessitates
+ // domain allow side effects if a block
+ // was in place when the allow was created.
+ //
+ // So, check if there's a block.
+ block, err := a.db.GetDomainBlock(ctx, allow.Domain)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ errs := gtserror.NewMultiError(1)
+ errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
+ return errs
+ }
+
+ if block == nil {
+ // No block?
+ // No problem!
+ return nil
+ }
+
+ // There was a block, over which the new
+ // allow ought to take precedence. To account
+ // for this, just run side effects as though
+ // the domain was being unblocked, while
+ // leaving the existing block in place.
+ //
+ // Any accounts that were suspended by
+ // the block will be unsuspended and be
+ // able to interact with the instance again.
+ return a.domainUnblockSideEffects(ctx, block)
+}
+
+// Returns an AdminActionF for
+// domain unallow side effects.
+func (a *Actions) DomainUnallowF(
+ actionID string,
+ domainAllow *gtsmodel.DomainAllow,
+) ActionF {
+ return func(ctx context.Context) gtserror.MultiError {
+ l := log.
+ WithContext(ctx).
+ WithFields(kv.Fields{
+ {"action", "unallow"},
+ {"actionID", actionID},
+ {"domain", domainAllow.Domain},
+ }...)
+
+ // Log start + finish.
+ l.Info("processing side effects")
+ errs := a.domainUnallowSideEffects(ctx, domainAllow)
+ l.Info("finished processing side effects")
+
+ return errs
+ }
+}
+
+func (a *Actions) domainUnallowSideEffects(
+ ctx context.Context,
+ allow *gtsmodel.DomainAllow,
+) gtserror.MultiError {
+ if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
+ // We're running in allowlist mode,
+ // so there are no side effects to
+ // process here.
+ return nil
+ }
+
+ // We're running in blocklist mode or
+ // some similar mode which necessitates
+ // domain allow side effects if a block
+ // was in place when the allow was removed.
+ //
+ // So, check if there's a block.
+ block, err := a.db.GetDomainBlock(ctx, allow.Domain)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ errs := gtserror.NewMultiError(1)
+ errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
+ return errs
+ }
+
+ if block == nil {
+ // No block?
+ // No problem!
+ return nil
+ }
+
+ // There was a block, over which the previous
+ // allow was taking precedence. Now that the
+ // allow has been removed, we should put the
+ // side effects of the block back in place.
+ //
+ // To do this, process the block side effects
+ // again as though the block were freshly
+ // created. This will mark all accounts from
+ // the blocked domain as suspended, and clean
+ // up their follows/following, media, etc.
+ return a.domainBlockSideEffects(ctx, block)
+}
+
+func (a *Actions) DomainBlockF(
+ actionID string,
+ domainBlock *gtsmodel.DomainBlock,
+) ActionF {
+ return func(ctx context.Context) gtserror.MultiError {
+ l := log.
+ WithContext(ctx).
+ WithFields(kv.Fields{
+ {"action", "block"},
+ {"actionID", actionID},
+ {"domain", domainBlock.Domain},
+ }...)
+
+ skip, err := a.skipBlockSideEffects(ctx, domainBlock.Domain)
+ if err != nil {
+ return err
+ }
+
+ if skip != "" {
+ l.Infof("skipping side effects: %s", skip)
+ return nil
+ }
+
+ l.Info("processing side effects")
+ errs := a.domainBlockSideEffects(ctx, domainBlock)
+ l.Info("finished processing side effects")
+
+ return errs
+ }
+}
+
+// domainBlockSideEffects processes the side effects of a domain block:
+//
+// 1. Strip most info away from the instance entry for the domain.
+// 2. Pass each account from the domain to the processor for deletion.
+//
+// It should be called asynchronously, since it can take a while when
+// there are many accounts present on the given domain.
+func (a *Actions) domainBlockSideEffects(
+ ctx context.Context,
+ block *gtsmodel.DomainBlock,
+) gtserror.MultiError {
+ var errs gtserror.MultiError
+
+ // If we have an instance entry for this domain,
+ // update it with the new block ID and clear all fields
+ instance, err := a.db.GetInstance(ctx, block.Domain)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ errs.Appendf("db error getting instance %s: %w", block.Domain, err)
+ return errs
+ }
+
+ if instance != nil {
+ // We had an entry for this domain.
+ columns := stubbifyInstance(instance, block.ID)
+ if err := a.db.UpdateInstance(ctx, instance, columns...); err != nil {
+ errs.Appendf("db error updating instance: %w", err)
+ return errs
+ }
+ }
+
+ // For each account that belongs to this domain,
+ // process an account delete message to remove
+ // that account's posts, media, etc.
+ if err := a.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
+ if err := a.workers.Client.Process(ctx, &messages.FromClientAPI{
+ APObjectType: ap.ActorPerson,
+ APActivityType: ap.ActivityDelete,
+ GTSModel: block,
+ Origin: account,
+ Target: account,
+ }); err != nil {
+ errs.Append(err)
+ }
+ }); err != nil {
+ errs.Appendf("db error ranging through accounts: %w", err)
+ }
+
+ return errs
+}
+
+func (a *Actions) DomainUnblockF(
+ actionID string,
+ domainBlock *gtsmodel.DomainBlock,
+) ActionF {
+ return func(ctx context.Context) gtserror.MultiError {
+ l := log.
+ WithContext(ctx).
+ WithFields(kv.Fields{
+ {"action", "unblock"},
+ {"actionID", actionID},
+ {"domain", domainBlock.Domain},
+ }...)
+
+ l.Info("processing side effects")
+ errs := a.domainUnblockSideEffects(ctx, domainBlock)
+ l.Info("finished processing side effects")
+
+ return errs
+ }
+}
+
+// domainUnblockSideEffects processes the side effects of undoing a
+// domain block:
+//
+// 1. Mark instance entry as no longer suspended.
+// 2. Mark each account from the domain as no longer suspended, if the
+// suspension origin corresponds to the ID of the provided domain block.
+//
+// It should be called asynchronously, since it can take a while when
+// there are many accounts present on the given domain.
+func (a *Actions) domainUnblockSideEffects(
+ ctx context.Context,
+ block *gtsmodel.DomainBlock,
+) gtserror.MultiError {
+ var errs gtserror.MultiError
+
+ // Update instance entry for this domain, if we have it.
+ instance, err := a.db.GetInstance(ctx, block.Domain)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ errs.Appendf("db error getting instance %s: %w", block.Domain, err)
+ }
+
+ if instance != nil {
+ // We had an entry, update it to signal
+ // that it's no longer suspended.
+ instance.SuspendedAt = time.Time{}
+ instance.DomainBlockID = ""
+ if err := a.db.UpdateInstance(
+ ctx,
+ instance,
+ "suspended_at",
+ "domain_block_id",
+ ); err != nil {
+ errs.Appendf("db error updating instance: %w", err)
+ return errs
+ }
+ }
+
+ // Unsuspend all accounts whose suspension origin was this domain block.
+ if err := a.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
+ if account.SuspensionOrigin == "" || account.SuspendedAt.IsZero() {
+ // Account wasn't suspended, nothing to do.
+ return
+ }
+
+ if account.SuspensionOrigin != block.ID {
+ // Account was suspended, but not by
+ // this domain block, leave it alone.
+ return
+ }
+
+ // Account was suspended by this domain
+ // block, mark it as unsuspended.
+ account.SuspendedAt = time.Time{}
+ account.SuspensionOrigin = ""
+
+ if err := a.db.UpdateAccount(
+ ctx,
+ account,
+ "suspended_at",
+ "suspension_origin",
+ ); err != nil {
+ errs.Appendf("db error updating account %s: %w", account.Username, err)
+ }
+ }); err != nil {
+ errs.Appendf("db error ranging through accounts: %w", err)
+ }
+
+ return errs
+}
+
+// skipBlockSideEffects checks if side effects of block creation
+// should be skipped for the given domain, taking account of
+// instance federation mode, and existence of any allows
+// which ought to "shield" this domain from being blocked.
+//
+// If the caller should skip, the returned string will be non-zero
+// and will be set to a reason why side effects should be skipped.
+//
+// - blocklist mode + allow exists: "..." (skip)
+// - blocklist mode + no allow: "" (don't skip)
+// - allowlist mode + allow exists: "" (don't skip)
+// - allowlist mode + no allow: "" (don't skip)
+func (a *Actions) skipBlockSideEffects(
+ ctx context.Context,
+ domain string,
+) (string, gtserror.MultiError) {
+ var (
+ skip string // Assume "" (don't skip).
+ errs gtserror.MultiError
+ )
+
+ // Never skip block side effects in allowlist mode.
+ fediMode := config.GetInstanceFederationMode()
+ if fediMode == config.InstanceFederationModeAllowlist {
+ return skip, errs
+ }
+
+ // We know we're in blocklist mode.
+ //
+ // We want to skip domain block side
+ // effects if an allow is already
+ // in place which overrides the block.
+
+ // Check if an explicit allow exists for this domain.
+ domainAllow, err := a.db.GetDomainAllow(ctx, domain)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ errs.Appendf("error getting domain allow: %w", err)
+ return skip, errs
+ }
+
+ if domainAllow != nil {
+ skip = "running in blocklist mode, and an explicit allow exists for this domain"
+ return skip, errs
+ }
+
+ return skip, errs
+}
diff --git a/internal/admin/util.go b/internal/admin/util.go
new file mode 100644
index 000000000..27e81fbb3
--- /dev/null
+++ b/internal/admin/util.go
@@ -0,0 +1,99 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package admin
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// stubbifyInstance renders the given instance as a stub,
+// removing most information from it and marking it as
+// suspended.
+//
+// For caller's convenience, this function returns the db
+// names of all columns that are updated by it.
+func stubbifyInstance(instance *gtsmodel.Instance, domainBlockID string) []string {
+ instance.Title = ""
+ instance.SuspendedAt = time.Now()
+ instance.DomainBlockID = domainBlockID
+ instance.ShortDescription = ""
+ instance.Description = ""
+ instance.Terms = ""
+ instance.ContactEmail = ""
+ instance.ContactAccountUsername = ""
+ instance.ContactAccountID = ""
+ instance.Version = ""
+
+ return []string{
+ "title",
+ "suspended_at",
+ "domain_block_id",
+ "short_description",
+ "description",
+ "terms",
+ "contact_email",
+ "contact_account_username",
+ "contact_account_id",
+ "version",
+ }
+}
+
+// rangeDomainAccounts iterates through all accounts
+// originating from the given domain, and calls the
+// provided range function on each account.
+//
+// If an error is returned while selecting accounts,
+// the loop will stop and return the error.
+func (a *Actions) rangeDomainAccounts(
+ ctx context.Context,
+ domain string,
+ rangeF func(*gtsmodel.Account),
+) error {
+ var (
+ limit = 50 // Limit selection to avoid spiking mem/cpu.
+ maxID string // Start with empty string to select from top.
+ )
+
+ for {
+ // Get (next) page of accounts.
+ accounts, err := a.db.GetInstanceAccounts(ctx, domain, maxID, limit)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Real db error.
+ return gtserror.Newf("db error getting instance accounts: %w", err)
+ }
+
+ if len(accounts) == 0 {
+ // No accounts left, we're done.
+ return nil
+ }
+
+ // Set next max ID for paging down.
+ maxID = accounts[len(accounts)-1].ID
+
+ // Call provided range function.
+ for _, account := range accounts {
+ rangeF(account)
+ }
+ }
+}