summaryrefslogtreecommitdiff
path: root/internal/processing/filters
diff options
context:
space:
mode:
authorLibravatar Vyr Cossont <VyrCossont@users.noreply.github.com>2024-03-06 02:15:58 -0800
committerLibravatar GitHub <noreply@github.com>2024-03-06 11:15:58 +0100
commit61a2b91f454a6eb0dd383fc8614fee154654fa08 (patch)
treefcf6159f00c3a0833e6647dd00cd03d03774e2b2 /internal/processing/filters
parent[chore]: Bump github.com/stretchr/testify from 1.8.4 to 1.9.0 (#2714) (diff)
downloadgotosocial-61a2b91f454a6eb0dd383fc8614fee154654fa08.tar.xz
[feature] Filters v1 (#2594)
* Implement client-side v1 filters * Exclude linter false positives * Update test/envparsing.sh * Fix minor Swagger, style, and Bun usage issues * Regenerate Swagger * De-generify filter keywords * Remove updating filter statuses This is an operation that the Mastodon v2 filter API doesn't actually have, because filter statuses, unlike keywords, don't have options: the only info they contain is the status ID to be filtered. * Add a test for filter statuses specifically * De-generify filter statuses * Inline FilterEntry * Use vertical style for Bun operations consistently * Add comment on Filter DB interface * Remove GoLand linter control comments Our existing linters should catch these, or they don't matter very much * Reduce memory ratio for filters
Diffstat (limited to 'internal/processing/filters')
-rw-r--r--internal/processing/filters/v1/convert.go38
-rw-r--r--internal/processing/filters/v1/create.go87
-rw-r--r--internal/processing/filters/v1/delete.go67
-rw-r--r--internal/processing/filters/v1/filters.go35
-rw-r--r--internal/processing/filters/v1/get.go78
-rw-r--r--internal/processing/filters/v1/update.go165
6 files changed, 470 insertions, 0 deletions
diff --git a/internal/processing/filters/v1/convert.go b/internal/processing/filters/v1/convert.go
new file mode 100644
index 000000000..1e0db5ff1
--- /dev/null
+++ b/internal/processing/filters/v1/convert.go
@@ -0,0 +1,38 @@
+// 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 v1
+
+import (
+ "context"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// apiFilter is a shortcut to return the API v1 filter version of the given
+// filter keyword, or return an appropriate error if conversion fails.
+func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (*apimodel.FilterV1, gtserror.WithCode) {
+ apiFilter, err := p.converter.FilterKeywordToAPIFilterV1(ctx, filterKeyword)
+ if err != nil {
+ return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter keyword to API v1 filter: %w", err))
+ }
+
+ return apiFilter, nil
+}
diff --git a/internal/processing/filters/v1/create.go b/internal/processing/filters/v1/create.go
new file mode 100644
index 000000000..e36d6800a
--- /dev/null
+++ b/internal/processing/filters/v1/create.go
@@ -0,0 +1,87 @@
+// 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 v1
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "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"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// Create a new filter and filter keyword for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateUpdateRequestV1) (*apimodel.FilterV1, gtserror.WithCode) {
+ filter := &gtsmodel.Filter{
+ ID: id.NewULID(),
+ AccountID: account.ID,
+ Title: form.Phrase,
+ Action: gtsmodel.FilterActionWarn,
+ }
+ if *form.Irreversible {
+ filter.Action = gtsmodel.FilterActionHide
+ }
+ if form.ExpiresIn != nil {
+ filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
+ }
+ for _, context := range form.Context {
+ switch context {
+ case apimodel.FilterContextHome:
+ filter.ContextHome = util.Ptr(true)
+ case apimodel.FilterContextNotifications:
+ filter.ContextNotifications = util.Ptr(true)
+ case apimodel.FilterContextPublic:
+ filter.ContextPublic = util.Ptr(true)
+ case apimodel.FilterContextThread:
+ filter.ContextThread = util.Ptr(true)
+ case apimodel.FilterContextAccount:
+ filter.ContextAccount = util.Ptr(true)
+ default:
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ fmt.Errorf("unsupported filter context '%s'", context),
+ )
+ }
+ }
+
+ filterKeyword := &gtsmodel.FilterKeyword{
+ ID: id.NewULID(),
+ AccountID: account.ID,
+ FilterID: filter.ID,
+ Filter: filter,
+ Keyword: form.Phrase,
+ WholeWord: util.Ptr(util.PtrValueOr(form.WholeWord, false)),
+ }
+ filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
+
+ if err := p.state.DB.PutFilter(ctx, filter); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("you already have a filter with this title")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.apiFilter(ctx, filterKeyword)
+}
diff --git a/internal/processing/filters/v1/delete.go b/internal/processing/filters/v1/delete.go
new file mode 100644
index 000000000..f2312f039
--- /dev/null
+++ b/internal/processing/filters/v1/delete.go
@@ -0,0 +1,67 @@
+// 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 v1
+
+import (
+ "context"
+ "errors"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Delete an existing filter keyword and (if empty afterwards) filter for the given account.
+func (p *Processor) Delete(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterKeywordID string,
+) gtserror.WithCode {
+ // Get enough of the filter keyword that we can look up its filter ID.
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return gtserror.NewErrorNotFound(err)
+ }
+ return gtserror.NewErrorInternalError(err)
+ }
+ if filterKeyword.AccountID != account.ID {
+ return gtserror.NewErrorNotFound(nil)
+ }
+
+ // Get the filter for this keyword.
+ filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID)
+ if err != nil {
+ return gtserror.NewErrorNotFound(err)
+ }
+
+ if len(filter.Keywords) > 1 || len(filter.Statuses) > 0 {
+ // The filter has other keywords or statuses. Delete only the requested filter keyword.
+ if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil {
+ return gtserror.NewErrorInternalError(err)
+ }
+ } else {
+ // Delete the entire filter.
+ if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil {
+ return gtserror.NewErrorInternalError(err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/processing/filters/v1/filters.go b/internal/processing/filters/v1/filters.go
new file mode 100644
index 000000000..d46c9e72c
--- /dev/null
+++ b/internal/processing/filters/v1/filters.go
@@ -0,0 +1,35 @@
+// 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 v1
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+type Processor struct {
+ state *state.State
+ converter *typeutils.Converter
+}
+
+func New(state *state.State, converter *typeutils.Converter) Processor {
+ return Processor{
+ state: state,
+ converter: converter,
+ }
+}
diff --git a/internal/processing/filters/v1/get.go b/internal/processing/filters/v1/get.go
new file mode 100644
index 000000000..39575dd94
--- /dev/null
+++ b/internal/processing/filters/v1/get.go
@@ -0,0 +1,78 @@
+// 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 v1
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "strings"
+
+ 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"
+)
+
+// Get looks up a filter keyword by ID and returns it as a v1 filter.
+func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterV1, gtserror.WithCode) {
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filterKeyword.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(nil)
+ }
+
+ return p.apiFilter(ctx, filterKeyword)
+}
+
+// GetAll looks up all filter keywords for the current account and returns them as v1 filters.
+func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV1, gtserror.WithCode) {
+ filters, err := p.state.DB.GetFilterKeywordsForAccountID(
+ ctx,
+ account.ID,
+ )
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, nil
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ apiFilters := make([]*apimodel.FilterV1, 0, len(filters))
+ for _, list := range filters {
+ apiFilter, errWithCode := p.apiFilter(ctx, list)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ apiFilters = append(apiFilters, apiFilter)
+ }
+
+ // Sort them by ID so that they're in a stable order.
+ // Clients may opt to sort them lexically in a locale-aware manner.
+ slices.SortFunc(apiFilters, func(lhs *apimodel.FilterV1, rhs *apimodel.FilterV1) int {
+ return strings.Compare(lhs.ID, rhs.ID)
+ })
+
+ return apiFilters, nil
+}
diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go
new file mode 100644
index 000000000..1fe49721b
--- /dev/null
+++ b/internal/processing/filters/v1/update.go
@@ -0,0 +1,165 @@
+// 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 v1
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "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/util"
+)
+
+// Update an existing filter and filter keyword for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) Update(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterKeywordID string,
+ form *apimodel.FilterCreateUpdateRequestV1,
+) (*apimodel.FilterV1, gtserror.WithCode) {
+ // Get enough of the filter keyword that we can look up its filter ID.
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filterKeyword.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(nil)
+ }
+
+ // Get the filter for this keyword.
+ filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ title := form.Phrase
+ action := gtsmodel.FilterActionWarn
+ if *form.Irreversible {
+ action = gtsmodel.FilterActionHide
+ }
+ expiresAt := time.Time{}
+ if form.ExpiresIn != nil {
+ expiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
+ }
+ contextHome := false
+ contextNotifications := false
+ contextPublic := false
+ contextThread := false
+ contextAccount := false
+ for _, context := range form.Context {
+ switch context {
+ case apimodel.FilterContextHome:
+ contextHome = true
+ case apimodel.FilterContextNotifications:
+ contextNotifications = true
+ case apimodel.FilterContextPublic:
+ contextPublic = true
+ case apimodel.FilterContextThread:
+ contextThread = true
+ case apimodel.FilterContextAccount:
+ contextAccount = true
+ default:
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ fmt.Errorf("unsupported filter context '%s'", context),
+ )
+ }
+ }
+
+ // v1 filter APIs can't change certain fields for a filter with multiple keywords or any statuses,
+ // since it would be an unexpected side effect on filters that, to the v1 API, appear separate.
+ // See https://docs.joinmastodon.org/methods/filters/#update-v1
+ if len(filter.Keywords) > 1 || len(filter.Statuses) > 0 {
+ forbiddenFields := make([]string, 0, 4)
+ if title != filter.Title {
+ forbiddenFields = append(forbiddenFields, "phrase")
+ }
+ if action != filter.Action {
+ forbiddenFields = append(forbiddenFields, "irreversible")
+ }
+ if expiresAt != filter.ExpiresAt {
+ forbiddenFields = append(forbiddenFields, "expires_in")
+ }
+ if contextHome != util.PtrValueOr(filter.ContextHome, false) ||
+ contextNotifications != util.PtrValueOr(filter.ContextNotifications, false) ||
+ contextPublic != util.PtrValueOr(filter.ContextPublic, false) ||
+ contextThread != util.PtrValueOr(filter.ContextThread, false) ||
+ contextAccount != util.PtrValueOr(filter.ContextAccount, false) {
+ forbiddenFields = append(forbiddenFields, "context")
+ }
+ if len(forbiddenFields) > 0 {
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ fmt.Errorf("v1 filter backwards compatibility: can't change these fields for a filter with multiple keywords or any statuses: %s", strings.Join(forbiddenFields, ", ")),
+ )
+ }
+ }
+
+ // Now that we've checked that the changes are legal, apply them to the filter and keyword.
+ filter.Title = title
+ filter.Action = action
+ filter.ExpiresAt = expiresAt
+ filter.ContextHome = &contextHome
+ filter.ContextNotifications = &contextNotifications
+ filter.ContextPublic = &contextPublic
+ filter.ContextThread = &contextThread
+ filter.ContextAccount = &contextAccount
+ filterKeyword.Keyword = form.Phrase
+ filterKeyword.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
+
+ // We only want to update the relevant filter keyword.
+ filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
+ filter.Statuses = nil
+ filterKeyword.Filter = filter
+
+ filterColumns := []string{
+ "title",
+ "action",
+ "expires_at",
+ "context_home",
+ "context_notifications",
+ "context_public",
+ "context_thread",
+ "context_account",
+ }
+ filterKeywordColumns := []string{
+ "keyword",
+ "whole_word",
+ }
+ if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, nil, nil); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("you already have a filter with this title")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.apiFilter(ctx, filterKeyword)
+}