diff options
Diffstat (limited to 'internal/processing/admin')
-rw-r--r-- | internal/processing/admin/domainallow.go | 255 | ||||
-rw-r--r-- | internal/processing/admin/domainblock.go | 305 | ||||
-rw-r--r-- | internal/processing/admin/domainblock_test.go | 76 | ||||
-rw-r--r-- | internal/processing/admin/domainpermission.go | 335 | ||||
-rw-r--r-- | internal/processing/admin/domainpermission_test.go | 280 | ||||
-rw-r--r-- | internal/processing/admin/util.go | 17 |
6 files changed, 944 insertions, 324 deletions
diff --git a/internal/processing/admin/domainallow.go b/internal/processing/admin/domainallow.go new file mode 100644 index 000000000..bab54e308 --- /dev/null +++ b/internal/processing/admin/domainallow.go @@ -0,0 +1,255 @@ +// 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" + "fmt" + + "codeberg.org/gruf/go-kv" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "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/id" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/text" +) + +func (p *Processor) createDomainAllow( + ctx context.Context, + adminAcct *gtsmodel.Account, + domain string, + obfuscate bool, + publicComment string, + privateComment string, + subscriptionID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + // Check if an allow already exists for this domain. + domainAllow, err := p.state.DB.GetDomainAllow(ctx, domain) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + // Something went wrong in the DB. + err = gtserror.Newf("db error getting domain allow %s: %w", domain, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + if domainAllow == nil { + // No allow exists yet, create it. + domainAllow = >smodel.DomainAllow{ + ID: id.NewULID(), + Domain: domain, + CreatedByAccountID: adminAcct.ID, + PrivateComment: text.SanitizeToPlaintext(privateComment), + PublicComment: text.SanitizeToPlaintext(publicComment), + Obfuscate: &obfuscate, + SubscriptionID: subscriptionID, + } + + // Insert the new allow into the database. + if err := p.state.DB.CreateDomainAllow(ctx, domainAllow); err != nil { + err = gtserror.Newf("db error putting domain allow %s: %w", domain, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + } + + actionID := id.NewULID() + + // Process domain allow side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domain, + Type: gtsmodel.AdminActionSuspend, + AccountID: adminAcct.ID, + Text: domainAllow.PrivateComment, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain allow side effects") + defer func() { l.Info("finished processing domain allow side effects") }() + + return p.domainAllowSideEffects(ctx, domainAllow) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false) + if errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainAllow, actionID, nil +} + +func (p *Processor) 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 := p.state.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 p.domainUnblockSideEffects(ctx, block) +} + +func (p *Processor) deleteDomainAllow( + ctx context.Context, + adminAcct *gtsmodel.Account, + domainAllowID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + domainAllow, err := p.state.DB.GetDomainAllowByID(ctx, domainAllowID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain allow: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain allow entry exists with ID %s", domainAllowID) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + // Prepare the domain allow to return, *before* the deletion goes through. + apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false) + if errWithCode != nil { + return nil, "", errWithCode + } + + // Delete the original domain allow. + if err := p.state.DB.DeleteDomainAllow(ctx, domainAllow.Domain); err != nil { + err = gtserror.Newf("db error deleting domain allow: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + actionID := id.NewULID() + + // Process domain unallow side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domainAllow.Domain, + Type: gtsmodel.AdminActionUnsuspend, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domainAllow.Domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain unallow side effects") + defer func() { l.Info("finished processing domain unallow side effects") }() + + return p.domainUnallowSideEffects(ctx, domainAllow) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainAllow, actionID, nil +} + +func (p *Processor) 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 := p.state.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 p.domainBlockSideEffects(ctx, block) +} diff --git a/internal/processing/admin/domainblock.go b/internal/processing/admin/domainblock.go index 1262bf6b0..4161ec12f 100644 --- a/internal/processing/admin/domainblock.go +++ b/internal/processing/admin/domainblock.go @@ -18,14 +18,9 @@ package admin import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" - "mime/multipart" - "net/http" "time" "codeberg.org/gruf/go-kv" @@ -40,14 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/text" ) -// DomainBlockCreate creates an instance-level block against the given domain, -// and then processes side effects of that block (deleting accounts, media, etc). -// -// If a domain block already exists for the domain, side effects will be retried. -// -// Return values for this function are the (new) domain block, the ID of the admin -// action resulting from this call, and/or an error if something goes wrong. -func (p *Processor) DomainBlockCreate( +func (p *Processor) createDomainBlock( ctx context.Context, adminAcct *gtsmodel.Account, domain string, @@ -55,7 +43,7 @@ func (p *Processor) DomainBlockCreate( publicComment string, privateComment string, subscriptionID string, -) (*apimodel.DomainBlock, string, gtserror.WithCode) { +) (*apimodel.DomainPermission, string, gtserror.WithCode) { // Check if a block already exists for this domain. domainBlock, err := p.state.DB.GetDomainBlock(ctx, domain) if err != nil && !errors.Is(err, db.ErrNoEntries) { @@ -98,13 +86,22 @@ func (p *Processor) DomainBlockCreate( Text: domainBlock.PrivateComment, }, func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain block side effects") + defer func() { l.Info("finished processing domain block side effects") }() + return p.domainBlockSideEffects(ctx, domainBlock) }, ); errWithCode != nil { return nil, actionID, errWithCode } - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false) if errWithCode != nil { return nil, actionID, errWithCode } @@ -112,206 +109,6 @@ func (p *Processor) DomainBlockCreate( return apiDomainBlock, actionID, nil } -// DomainBlockDelete removes one domain block with the given ID, -// and processes side effects of removing the block asynchronously. -// -// Return values for this function are the deleted domain block, the ID of the admin -// action resulting from this call, and/or an error if something goes wrong. -func (p *Processor) DomainBlockDelete( - ctx context.Context, - adminAcct *gtsmodel.Account, - domainBlockID string, -) (*apimodel.DomainBlock, string, gtserror.WithCode) { - domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID) - if err != nil { - if !errors.Is(err, db.ErrNoEntries) { - // Real error. - err = gtserror.Newf("db error getting domain block: %w", err) - return nil, "", gtserror.NewErrorInternalError(err) - } - - // There are just no entries for this ID. - err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID) - return nil, "", gtserror.NewErrorNotFound(err, err.Error()) - } - - // Prepare the domain block to return, *before* the deletion goes through. - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) - if errWithCode != nil { - return nil, "", errWithCode - } - - // Copy value of the domain block. - domainBlockC := new(gtsmodel.DomainBlock) - *domainBlockC = *domainBlock - - // Delete the original domain block. - if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil { - err = gtserror.Newf("db error deleting domain block: %w", err) - return nil, "", gtserror.NewErrorInternalError(err) - } - - actionID := id.NewULID() - - // Process domain unblock side - // effects asynchronously. - if errWithCode := p.actions.Run( - ctx, - >smodel.AdminAction{ - ID: actionID, - TargetCategory: gtsmodel.AdminActionCategoryDomain, - TargetID: domainBlockC.Domain, - Type: gtsmodel.AdminActionUnsuspend, - AccountID: adminAcct.ID, - }, - func(ctx context.Context) gtserror.MultiError { - return p.domainUnblockSideEffects(ctx, domainBlock) - }, - ); errWithCode != nil { - return nil, actionID, errWithCode - } - - return apiDomainBlock, actionID, nil -} - -// DomainBlocksImport handles the import of multiple domain blocks, -// by calling the DomainBlockCreate function for each domain in the -// provided file. Will return a slice of processed domain blocks. -// -// In the case of total failure, a gtserror.WithCode will be returned -// so that the caller can respond appropriately. In the case of -// partial or total success, a MultiStatus model will be returned, -// which contains information about success/failure count, so that -// the caller can retry any failures as they wish. -func (p *Processor) DomainBlocksImport( - ctx context.Context, - account *gtsmodel.Account, - domainsF *multipart.FileHeader, -) (*apimodel.MultiStatus, gtserror.WithCode) { - // Open the provided file. - file, err := domainsF.Open() - if err != nil { - err = gtserror.Newf("error opening attachment: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - defer file.Close() - - // Copy the file contents into a buffer. - buf := new(bytes.Buffer) - size, err := io.Copy(buf, file) - if err != nil { - err = gtserror.Newf("error reading attachment: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Ensure we actually read something. - if size == 0 { - err = gtserror.New("error reading attachment: size 0 bytes") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Parse bytes as slice of domain blocks. - domainBlocks := make([]*apimodel.DomainBlock, 0) - if err := json.Unmarshal(buf.Bytes(), &domainBlocks); err != nil { - err = gtserror.Newf("error parsing attachment as domain blocks: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - count := len(domainBlocks) - if count == 0 { - err = gtserror.New("error importing domain blocks: 0 entries provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Try to process each domain block, differentiating - // between successes and errors so that the caller can - // try failed imports again if desired. - multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count) - - for _, domainBlock := range domainBlocks { - var ( - domain = domainBlock.Domain.Domain - obfuscate = domainBlock.Obfuscate - publicComment = domainBlock.PublicComment - privateComment = domainBlock.PrivateComment - subscriptionID = "" // No sub ID for imports. - errWithCode gtserror.WithCode - ) - - domainBlock, _, errWithCode = p.DomainBlockCreate( - ctx, - account, - domain, - obfuscate, - publicComment, - privateComment, - subscriptionID, - ) - - var entry *apimodel.MultiStatusEntry - - if errWithCode != nil { - entry = &apimodel.MultiStatusEntry{ - // Use the failed domain entry as the resource value. - Resource: domain, - Message: errWithCode.Safe(), - Status: errWithCode.Code(), - } - } else { - entry = &apimodel.MultiStatusEntry{ - // Use successfully created API model domain block as the resource value. - Resource: domainBlock, - Message: http.StatusText(http.StatusOK), - Status: http.StatusOK, - } - } - - multiStatusEntries = append(multiStatusEntries, *entry) - } - - return apimodel.NewMultiStatus(multiStatusEntries), nil -} - -// DomainBlocksGet returns all existing domain blocks. If export is -// true, the format will be suitable for writing out to an export. -func (p *Processor) DomainBlocksGet(ctx context.Context, account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) { - domainBlocks, err := p.state.DB.GetDomainBlocks(ctx) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("db error getting domain blocks: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - apiDomainBlocks := make([]*apimodel.DomainBlock, 0, len(domainBlocks)) - for _, domainBlock := range domainBlocks { - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) - if errWithCode != nil { - return nil, errWithCode - } - - apiDomainBlocks = append(apiDomainBlocks, apiDomainBlock) - } - - return apiDomainBlocks, nil -} - -// DomainBlockGet returns one domain block with the given id. If export -// is true, the format will be suitable for writing out to an export. -func (p *Processor) DomainBlockGet(ctx context.Context, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) { - domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, id) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("no domain block exists with id %s", id) - return nil, gtserror.NewErrorNotFound(err, err.Error()) - } - - // Something went wrong in the DB. - err = gtserror.Newf("db error getting domain block %s: %w", id, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return p.apiDomainBlock(ctx, domainBlock) -} - // domainBlockSideEffects processes the side effects of a domain block: // // 1. Strip most info away from the instance entry for the domain. @@ -323,13 +120,6 @@ func (p *Processor) domainBlockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain block side effects") - var errs gtserror.MultiError // If we have an instance entry for this domain, @@ -347,7 +137,6 @@ func (p *Processor) domainBlockSideEffects( errs.Appendf("db error updating instance: %w", err) return errs } - l.Debug("instance entry updated") } // For each account that belongs to this domain, @@ -372,6 +161,68 @@ func (p *Processor) domainBlockSideEffects( return errs } +func (p *Processor) deleteDomainBlock( + ctx context.Context, + adminAcct *gtsmodel.Account, + domainBlockID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain block: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + // Prepare the domain block to return, *before* the deletion goes through. + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false) + if errWithCode != nil { + return nil, "", errWithCode + } + + // Delete the original domain block. + if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil { + err = gtserror.Newf("db error deleting domain block: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + actionID := id.NewULID() + + // Process domain unblock side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domainBlock.Domain, + Type: gtsmodel.AdminActionUnsuspend, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domainBlock.Domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain unblock side effects") + defer func() { l.Info("finished processing domain unblock side effects") }() + + return p.domainUnblockSideEffects(ctx, domainBlock) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainBlock, actionID, nil +} + // domainUnblockSideEffects processes the side effects of undoing a // domain block: // @@ -385,13 +236,6 @@ func (p *Processor) domainUnblockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain unblock side effects") - var errs gtserror.MultiError // Update instance entry for this domain, if we have it. @@ -414,7 +258,6 @@ func (p *Processor) domainUnblockSideEffects( errs.Appendf("db error updating instance: %w", err) return errs } - l.Debug("instance entry updated") } // Unsuspend all accounts whose suspension origin was this domain block. diff --git a/internal/processing/admin/domainblock_test.go b/internal/processing/admin/domainblock_test.go deleted file mode 100644 index 9525ce7c3..000000000 --- a/internal/processing/admin/domainblock_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// 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" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type DomainBlockTestSuite struct { - AdminStandardTestSuite -} - -func (suite *DomainBlockTestSuite) TestCreateDomainBlock() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - domain = "fossbros-anonymous.io" - obfuscate = false - publicComment = "" - privateComment = "" - subscriptionID = "" - ) - - apiBlock, actionID, errWithCode := suite.adminProcessor.DomainBlockCreate( - ctx, - adminAcct, - domain, - obfuscate, - publicComment, - privateComment, - subscriptionID, - ) - suite.NoError(errWithCode) - suite.NotNil(apiBlock) - suite.NotEmpty(actionID) - - // Wait for action to finish. - if !testrig.WaitFor(func() bool { - return suite.adminProcessor.Actions().TotalRunning() == 0 - }) { - suite.FailNow("timed out waiting for admin action(s) to finish") - } - - // Ensure action marked as - // completed in the database. - adminAction, err := suite.db.GetAdminAction(ctx, actionID) - if err != nil { - suite.FailNow(err.Error()) - } - - suite.NotZero(adminAction.CompletedAt) - suite.Empty(adminAction.Errors) -} - -func TestDomainBlockTestSuite(t *testing.T) { - suite.Run(t, new(DomainBlockTestSuite)) -} diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go new file mode 100644 index 000000000..c759c0f11 --- /dev/null +++ b/internal/processing/admin/domainpermission.go @@ -0,0 +1,335 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "net/http" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// apiDomainPerm is a cheeky shortcut for returning +// the API version of the given domain permission +// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow), +// or an appropriate error if something goes wrong. +func (p *Processor) apiDomainPerm( + ctx context.Context, + domainPermission gtsmodel.DomainPermission, + export bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + apiDomainPerm, err := p.tc.DomainPermToAPIDomainPerm(ctx, domainPermission, export) + if err != nil { + err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiDomainPerm, nil +} + +// DomainPermissionCreate creates an instance-level permission +// targeting the given domain, and then processes any side +// effects of the permission creation. +// +// If the same permission type already exists for the domain, +// side effects will be retried. +// +// Return values for this function are the new or existing +// domain permission, the ID of the admin action resulting +// from this call, and/or an error if something goes wrong. +func (p *Processor) DomainPermissionCreate( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + adminAcct *gtsmodel.Account, + domain string, + obfuscate bool, + publicComment string, + privateComment string, + subscriptionID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + switch permissionType { + + // Explicitly block a domain. + case gtsmodel.DomainPermissionBlock: + return p.createDomainBlock( + ctx, + adminAcct, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // Explicitly allow a domain. + case gtsmodel.DomainPermissionAllow: + return p.createDomainAllow( + ctx, + adminAcct, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // Weeping, roaring, red-faced. + default: + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, "", gtserror.NewErrorInternalError(err) + } +} + +// DomainPermissionDelete removes one domain block with the given ID, +// and processes side effects of removing the block asynchronously. +// +// Return values for this function are the deleted domain block, the ID of the admin +// action resulting from this call, and/or an error if something goes wrong. +func (p *Processor) DomainPermissionDelete( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + adminAcct *gtsmodel.Account, + domainBlockID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + switch permissionType { + + // Delete explicit domain block. + case gtsmodel.DomainPermissionBlock: + return p.deleteDomainBlock( + ctx, + adminAcct, + domainBlockID, + ) + + // Delete explicit domain allow. + case gtsmodel.DomainPermissionAllow: + return p.deleteDomainAllow( + ctx, + adminAcct, + domainBlockID, + ) + + // You do the hokey-cokey and you turn + // around, that's what it's all about. + default: + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, "", gtserror.NewErrorInternalError(err) + } +} + +// DomainPermissionsImport handles the import of multiple +// domain permissions, by calling the DomainPermissionCreate +// function for each domain in the provided file. Will return +// a slice of processed domain permissions. +// +// In the case of total failure, a gtserror.WithCode will be +// returned so that the caller can respond appropriately. In +// the case of partial or total success, a MultiStatus model +// will be returned, which contains information about success +// + failure count, so that the caller can retry any failures +// as they wish. +func (p *Processor) DomainPermissionsImport( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + account *gtsmodel.Account, + domainsF *multipart.FileHeader, +) (*apimodel.MultiStatus, gtserror.WithCode) { + // Ensure known permission type. + if permissionType != gtsmodel.DomainPermissionBlock && + permissionType != gtsmodel.DomainPermissionAllow { + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, gtserror.NewErrorInternalError(err) + } + + // Open the provided file. + file, err := domainsF.Open() + if err != nil { + err = gtserror.Newf("error opening attachment: %w", err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + defer file.Close() + + // Parse file as slice of domain blocks. + domainPerms := make([]*apimodel.DomainPermission, 0) + if err := json.NewDecoder(file).Decode(&domainPerms); err != nil { + err = gtserror.Newf("error parsing attachment as domain permissions: %w", err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + count := len(domainPerms) + if count == 0 { + err = gtserror.New("error importing domain permissions: 0 entries provided") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + // Try to process each domain permission, differentiating + // between successes and errors so that the caller can + // try failed imports again if desired. + multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count) + + for _, domainPerm := range domainPerms { + var ( + domain = domainPerm.Domain.Domain + obfuscate = domainPerm.Obfuscate + publicComment = domainPerm.PublicComment + privateComment = domainPerm.PrivateComment + subscriptionID = "" // No sub ID for imports. + errWithCode gtserror.WithCode + ) + + domainPerm, _, errWithCode = p.DomainPermissionCreate( + ctx, + permissionType, + account, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + var entry *apimodel.MultiStatusEntry + + if errWithCode != nil { + entry = &apimodel.MultiStatusEntry{ + // Use the failed domain entry as the resource value. + Resource: domain, + Message: errWithCode.Safe(), + Status: errWithCode.Code(), + } + } else { + entry = &apimodel.MultiStatusEntry{ + // Use successfully created API model domain block as the resource value. + Resource: domainPerm, + Message: http.StatusText(http.StatusOK), + Status: http.StatusOK, + } + } + + multiStatusEntries = append(multiStatusEntries, *entry) + } + + return apimodel.NewMultiStatus(multiStatusEntries), nil +} + +// DomainPermissionsGet returns all existing domain +// permissions of the requested type. If export is +// true, the format will be suitable for writing out +// to an export. +func (p *Processor) DomainPermissionsGet( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + account *gtsmodel.Account, + export bool, +) ([]*apimodel.DomainPermission, gtserror.WithCode) { + var ( + domainPerms []gtsmodel.DomainPermission + err error + ) + + switch permissionType { + case gtsmodel.DomainPermissionBlock: + var blocks []*gtsmodel.DomainBlock + + blocks, err = p.state.DB.GetDomainBlocks(ctx) + if err != nil { + break + } + + for _, block := range blocks { + domainPerms = append(domainPerms, block) + } + + case gtsmodel.DomainPermissionAllow: + var allows []*gtsmodel.DomainAllow + + allows, err = p.state.DB.GetDomainAllows(ctx) + if err != nil { + break + } + + for _, allow := range allows { + domainPerms = append(domainPerms, allow) + } + + default: + err = errors.New("unrecognized permission type") + } + + if err != nil { + err := gtserror.Newf("error getting %ss: %w", permissionType.String(), err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiDomainPerms := make([]*apimodel.DomainPermission, len(domainPerms)) + for i, domainPerm := range domainPerms { + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainPerm, export) + if errWithCode != nil { + return nil, errWithCode + } + + apiDomainPerms[i] = apiDomainBlock + } + + return apiDomainPerms, nil +} + +// DomainPermissionGet returns one domain +// permission with the given id and type. +// +// If export is true, the format will be +// suitable for writing out to an export. +func (p *Processor) DomainPermissionGet( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + id string, + export bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + var ( + domainPerm gtsmodel.DomainPermission + err error + ) + + switch permissionType { + case gtsmodel.DomainPermissionBlock: + domainPerm, err = p.state.DB.GetDomainBlockByID(ctx, id) + case gtsmodel.DomainPermissionAllow: + domainPerm, err = p.state.DB.GetDomainAllowByID(ctx, id) + default: + err = gtserror.New("unrecognized permission type") + } + + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("no domain %s exists with id %s", permissionType.String(), id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + err = gtserror.Newf("error getting domain %s with id %s: %w", permissionType.String(), id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, domainPerm, export) +} diff --git a/internal/processing/admin/domainpermission_test.go b/internal/processing/admin/domainpermission_test.go new file mode 100644 index 000000000..b6de226c1 --- /dev/null +++ b/internal/processing/admin/domainpermission_test.go @@ -0,0 +1,280 @@ +// 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" + "testing" + + "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type DomainBlockTestSuite struct { + AdminStandardTestSuite +} + +type domainPermAction struct { + // 'create' or 'delete' + // the domain permission. + createOrDelete string + + // Type of permission + // to create or delete. + permissionType gtsmodel.DomainPermissionType + + // Domain to target + // with the permission. + domain string + + // Expected result of this + // permission action on each + // account on the target domain. + // Eg., suite.Zero(account.SuspendedAt) + expected func(*gtsmodel.Account) bool +} + +type domainPermTest struct { + // Federation mode under which to + // run this test. This is important + // because it may effect which side + // effects are taken, if any. + instanceFederationMode string + + // Series of actions to run as part + // of this test. After each action, + // expected will be called. This + // allows testers to run multiple + // actions in a row and check that + // the results after each action are + // what they expected, in light of + // previous actions. + actions []domainPermAction +} + +// run a domainPermTest by running each of +// its actions in turn and checking results. +func (suite *DomainBlockTestSuite) runDomainPermTest(t domainPermTest) { + config.SetInstanceFederationMode(t.instanceFederationMode) + + for _, action := range t.actions { + // Run the desired action. + var actionID string + switch action.createOrDelete { + case "create": + _, actionID = suite.createDomainPerm(action.permissionType, action.domain) + case "delete": + _, actionID = suite.deleteDomainPerm(action.permissionType, action.domain) + default: + panic("createOrDelete was not 'create' or 'delete'") + } + + // Let the action finish. + suite.awaitAction(actionID) + + // Check expected results + // against each account. + accounts, err := suite.db.GetInstanceAccounts( + context.Background(), + action.domain, + "", 0, + ) + if err != nil { + suite.FailNow("", "error getting instance accounts for %s: %v", action.domain, err) + } + + for _, account := range accounts { + if !action.expected(account) { + suite.T().FailNow() + } + } + } +} + +// create given permissionType with default values. +func (suite *DomainBlockTestSuite) createDomainPerm( + permissionType gtsmodel.DomainPermissionType, + domain string, +) (*apimodel.DomainPermission, string) { + ctx := context.Background() + + apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionCreate( + ctx, + permissionType, + suite.testAccounts["admin_account"], + domain, + false, + "", + "", + "", + ) + suite.NoError(errWithCode) + suite.NotNil(apiPerm) + suite.NotEmpty(actionID) + + return apiPerm, actionID +} + +// delete given permission type. +func (suite *DomainBlockTestSuite) deleteDomainPerm( + permissionType gtsmodel.DomainPermissionType, + domain string, +) (*apimodel.DomainPermission, string) { + var ( + ctx = context.Background() + domainPermission gtsmodel.DomainPermission + ) + + // To delete the permission, + // first get it from the db. + switch permissionType { + case gtsmodel.DomainPermissionBlock: + domainPermission, _ = suite.db.GetDomainBlock(ctx, domain) + case gtsmodel.DomainPermissionAllow: + domainPermission, _ = suite.db.GetDomainAllow(ctx, domain) + default: + panic("unrecognized permission type") + } + + if domainPermission == nil { + suite.FailNow("domain permission was nil") + } + + // Now use the ID to delete it. + apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionDelete( + ctx, + permissionType, + suite.testAccounts["admin_account"], + domainPermission.GetID(), + ) + suite.NoError(errWithCode) + suite.NotNil(apiPerm) + suite.NotEmpty(actionID) + + return apiPerm, actionID +} + +// waits for given actionID to be completed. +func (suite *DomainBlockTestSuite) awaitAction(actionID string) { + ctx := context.Background() + + if !testrig.WaitFor(func() bool { + return suite.adminProcessor.Actions().TotalRunning() == 0 + }) { + suite.FailNow("timed out waiting for admin action(s) to finish") + } + + // Ensure action marked as + // completed in the database. + adminAction, err := suite.db.GetAdminAction(ctx, actionID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotZero(adminAction.CompletedAt) + suite.Empty(adminAction.Errors) +} + +func (suite *DomainBlockTestSuite) TestBlockAndUnblockDomain() { + const domain = "fossbros-anonymous.io" + + suite.runDomainPermTest(domainPermTest{ + instanceFederationMode: config.InstanceFederationModeBlocklist, + actions: []domainPermAction{ + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was blocked, so each + // account should now be suspended. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was unblocked, so each + // account should now be unsuspended. + return suite.Zero(account.SuspendedAt) + }, + }, + }, + }) +} + +func (suite *DomainBlockTestSuite) TestBlockAndAllowDomain() { + const domain = "fossbros-anonymous.io" + + suite.runDomainPermTest(domainPermTest{ + instanceFederationMode: config.InstanceFederationModeBlocklist, + actions: []domainPermAction{ + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was blocked, so each + // account should now be suspended. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionAllow, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was explicitly allowed, so each + // account should now be unsuspended, since + // the allow supercedes the block. + return suite.Zero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionAllow, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Deleting the allow now, while there's + // still a block in place, should cause + // the block to take effect again. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Deleting the block now should + // unsuspend the accounts again. + return suite.Zero(account.SuspendedAt) + }, + }, + }, + }) +} + +func TestDomainBlockTestSuite(t *testing.T) { + suite.Run(t, new(DomainBlockTestSuite)) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index 403602901..c82ff2dc1 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -22,28 +22,11 @@ import ( "errors" "time" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -// apiDomainBlock is a cheeky shortcut for returning -// the API version of the given domainBlock, or an -// appropriate error if something goes wrong. -func (p *Processor) apiDomainBlock( - ctx context.Context, - domainBlock *gtsmodel.DomainBlock, -) (*apimodel.DomainBlock, gtserror.WithCode) { - apiDomainBlock, err := p.tc.DomainBlockToAPIDomainBlock(ctx, domainBlock, false) - if err != nil { - err = gtserror.Newf("error converting domain block for %s to api model : %w", domainBlock.Domain, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return apiDomainBlock, nil -} - // stubbifyInstance renders the given instance as a stub, // removing most information from it and marking it as // suspended. |