diff options
author | 2024-03-06 02:15:58 -0800 | |
---|---|---|
committer | 2024-03-06 11:15:58 +0100 | |
commit | 61a2b91f454a6eb0dd383fc8614fee154654fa08 (patch) | |
tree | fcf6159f00c3a0833e6647dd00cd03d03774e2b2 /internal/processing | |
parent | [chore]: Bump github.com/stretchr/testify from 1.8.4 to 1.9.0 (#2714) (diff) | |
download | gotosocial-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')
-rw-r--r-- | internal/processing/filters/v1/convert.go | 38 | ||||
-rw-r--r-- | internal/processing/filters/v1/create.go | 87 | ||||
-rw-r--r-- | internal/processing/filters/v1/delete.go | 67 | ||||
-rw-r--r-- | internal/processing/filters/v1/filters.go | 35 | ||||
-rw-r--r-- | internal/processing/filters/v1/get.go | 78 | ||||
-rw-r--r-- | internal/processing/filters/v1/update.go | 165 | ||||
-rw-r--r-- | internal/processing/processor.go | 35 | ||||
-rw-r--r-- | internal/processing/status/get.go | 1 |
8 files changed, 491 insertions, 15 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 := >smodel.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 := >smodel.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) +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index bb46d31a9..4aaa94fb7 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/admin" "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/fedi" + filtersv1 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v1" "github.com/superseriousbusiness/gotosocial/internal/processing/list" "github.com/superseriousbusiness/gotosocial/internal/processing/markers" "github.com/superseriousbusiness/gotosocial/internal/processing/media" @@ -68,20 +69,21 @@ type Processor struct { SUB-PROCESSORS */ - account account.Processor - admin admin.Processor - fedi fedi.Processor - list list.Processor - markers markers.Processor - media media.Processor - polls polls.Processor - report report.Processor - search search.Processor - status status.Processor - stream stream.Processor - timeline timeline.Processor - user user.Processor - workers workers.Processor + account account.Processor + admin admin.Processor + fedi fedi.Processor + filtersv1 filtersv1.Processor + list list.Processor + markers markers.Processor + media media.Processor + polls polls.Processor + report report.Processor + search search.Processor + status status.Processor + stream stream.Processor + timeline timeline.Processor + user user.Processor + workers workers.Processor } func (p *Processor) Account() *account.Processor { @@ -96,6 +98,10 @@ func (p *Processor) Fedi() *fedi.Processor { return &p.fedi } +func (p *Processor) FiltersV1() *filtersv1.Processor { + return &p.filtersv1 +} + func (p *Processor) List() *list.Processor { return &p.list } @@ -177,6 +183,7 @@ func NewProcessor( processor.account = account.New(&common, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) processor.fedi = fedi.New(state, &common, converter, federator, filter) + processor.filtersv1 = filtersv1.New(state, converter) processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) processor.polls = polls.New(&common, state, converter) diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 475ab0128..7256d2f82 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -111,7 +111,6 @@ func (p *Processor) contextGet( TopoSort(descendants, targetStatus.AccountID) - //goland:noinspection GoImportUsedAsName context := &apimodel.Context{ Ancestors: make([]apimodel.Status, 0, len(ancestors)), Descendants: make([]apimodel.Status, 0, len(descendants)), |