Compare commits

...

5 Commits

Author SHA1 Message Date
tobi 505af76bae [bugfix] Reset emoji fields on upload error 2024-05-07 19:22:16 +02:00
kim f456bd3401
update the total ratios calculation to include ALL caches (previously was missing a few!) (#2907) 2024-05-06 22:29:31 +01:00
kim 3554991444
update go-structr -> v0.8.2 which includes some minor memory usage improvements (#2904) 2024-05-06 19:44:22 +00:00
Vyr Cossont 45f4afe60e
feature: filters v2 server-side warning/hiding (#2793)
* Remove dead code

* Filter statuses when converting to frontend representation

* status.filtered is an array

* Make matching case-insensitive

* Remove TODOs that don't need to be done now

* Add missing filter check for notification

* lint: rename ErrHideStatus

* APIFilterActionToFilterAction not used yet

* swaggerino docseroni

* Address review comments

* Add apimodel.FilterActionNone

---------

Co-authored-by: tobi <31960611+tsmethurst@users.noreply.github.com>
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
2024-05-06 12:49:08 +01:00
dependabot[bot] a0d066844f
[chore]: Bump golang.org/x/oauth2 from 0.19.0 to 0.20.0 (#2900) 2024-05-06 11:14:04 +00:00
39 changed files with 1105 additions and 266 deletions

View File

@ -1,5 +1,9 @@
basePath: /
definitions:
FilterAction:
title: FilterAction is the action to apply to statuses matching a filter.
type: string
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
InstanceConfigurationEmojis:
properties:
emoji_size_limit:
@ -1037,6 +1041,60 @@ definitions:
type: string
x-go-name: FilterContext
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterKeyword:
properties:
id:
description: The ID of the filter keyword entry in the database.
type: string
x-go-name: ID
keyword:
description: The text to be filtered.
example: fnord
type: string
x-go-name: Keyword
whole_word:
description: Should the filter consider word boundaries?
example: true
type: boolean
x-go-name: WholeWord
title: FilterKeyword represents text to filter within a v2 filter.
type: object
x-go-name: FilterKeyword
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterResult:
properties:
filter:
$ref: '#/definitions/filterV2'
keyword_matches:
description: The keywords within the filter that were matched.
items:
type: string
type: array
x-go-name: KeywordMatches
status_matches:
description: The status IDs within the filter that were matched.
items:
type: string
type: array
x-go-name: StatusMatches
title: FilterResult is returned along with a filtered status to explain why it was filtered.
type: object
x-go-name: FilterResult
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterStatus:
properties:
id:
description: The ID of the filter status entry in the database.
type: string
x-go-name: ID
phrase:
description: The status ID to be filtered.
type: string
x-go-name: StatusID
title: FilterStatus represents a single status to filter within a v2 filter.
type: object
x-go-name: FilterStatus
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterV1:
description: |-
Note that v1 filters are mapped to v2 filters and v2 filter keywords internally.
@ -1086,6 +1144,52 @@ definitions:
type: object
x-go-name: FilterV1
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterV2:
description: v2 filters have names and can include multiple phrases and status IDs to filter.
properties:
context:
description: The contexts in which the filter should be applied.
example:
- home
- public
items:
$ref: '#/definitions/filterContext'
minItems: 1
type: array
uniqueItems: true
x-go-name: Context
expires_at:
description: When the filter should no longer be applied. Null if the filter does not expire.
example: "2024-02-01T02:57:49Z"
type: string
x-go-name: ExpiresAt
filter_action:
$ref: '#/definitions/FilterAction'
id:
description: The ID of the filter in the database.
type: string
x-go-name: ID
keywords:
description: The keywords grouped under this filter.
items:
$ref: '#/definitions/filterKeyword'
type: array
x-go-name: Keywords
statuses:
description: The statuses grouped under this filter.
items:
$ref: '#/definitions/filterStatus'
type: array
x-go-name: Statuses
title:
description: The name of the filter.
example: Linux Words
type: string
x-go-name: Title
title: FilterV2 represents a user-defined filter for determining which statuses should not be shown to the user.
type: object
x-go-name: FilterV2
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
headerFilter:
properties:
created_at:
@ -2118,6 +2222,12 @@ definitions:
format: int64
type: integer
x-go-name: FavouritesCount
filtered:
description: A list of filters that matched this status and why they matched, if there are any such filters.
items:
$ref: '#/definitions/filterResult'
type: array
x-go-name: Filtered
id:
description: ID of the status.
example: 01FBVD42CQ3ZEEVMW180SBX03B
@ -2321,6 +2431,12 @@ definitions:
format: int64
type: integer
x-go-name: FavouritesCount
filtered:
description: A list of filters that matched this status and why they matched, if there are any such filters.
items:
$ref: '#/definitions/filterResult'
type: array
x-go-name: Filtered
id:
description: ID of the status.
example: 01FBVD42CQ3ZEEVMW180SBX03B

4
go.mod
View File

@ -22,7 +22,7 @@ require (
codeberg.org/gruf/go-runners v1.6.2
codeberg.org/gruf/go-sched v1.2.3
codeberg.org/gruf/go-store/v2 v2.2.4
codeberg.org/gruf/go-structr v0.8.0
codeberg.org/gruf/go-structr v0.8.2
codeberg.org/superseriousbusiness/exif-terminator v0.7.0
github.com/DmitriyVTitov/size v1.5.0
github.com/KimMachineGun/automemlimit v0.6.0
@ -75,7 +75,7 @@ require (
golang.org/x/crypto v0.22.0
golang.org/x/image v0.16.0
golang.org/x/net v0.24.0
golang.org/x/oauth2 v0.19.0
golang.org/x/oauth2 v0.20.0
golang.org/x/text v0.15.0
gopkg.in/mcuadros/go-syslog.v2 v2.3.0
gopkg.in/yaml.v3 v3.0.1

8
go.sum
View File

@ -74,8 +74,8 @@ codeberg.org/gruf/go-sched v1.2.3 h1:H5ViDxxzOBR3uIyGBCf0eH8b1L8wMybOXcdtUUTXZHk
codeberg.org/gruf/go-sched v1.2.3/go.mod h1:vT9uB6KWFIIwnG9vcPY2a0alYNoqdL1mSzRM8I+PK7A=
codeberg.org/gruf/go-store/v2 v2.2.4 h1:8HO1Jh2gg7boQKA3hsDAIXd9zwieu5uXwDXEcTOD9js=
codeberg.org/gruf/go-store/v2 v2.2.4/go.mod h1:zI4VWe5CpXAktYMtaBMrgA5QmO0sQH53LBRvfn1huys=
codeberg.org/gruf/go-structr v0.8.0 h1:aZ+ziv2R6zTU16PW7B2d349wY9Du3mObc3hCeUIqtME=
codeberg.org/gruf/go-structr v0.8.0/go.mod h1:K1FXkUyO6N/JKt8aWqyQ8rtW7Z9ZmXKWP8mFAQ2OJjE=
codeberg.org/gruf/go-structr v0.8.2 h1:0zH5HuOZWTVOGqIq8o5W1jRi944CQWdPXUWZfKUbF0o=
codeberg.org/gruf/go-structr v0.8.2/go.mod h1:K1FXkUyO6N/JKt8aWqyQ8rtW7Z9ZmXKWP8mFAQ2OJjE=
codeberg.org/superseriousbusiness/exif-terminator v0.7.0 h1:Y6VApSXhKqExG0H2hZ2JelRK4xmWdjDQjn13CpEfzko=
codeberg.org/superseriousbusiness/exif-terminator v0.7.0/go.mod h1:gCWKduudUWFzsnixoMzu0FYVdxHWG+AbXnZ50DqxsUE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@ -756,8 +756,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -0,0 +1,34 @@
// 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 model
// FilterResult is returned along with a filtered status to explain why it was filtered.
//
// swagger:model filterResult
//
// ---
// tags:
// - filters
type FilterResult struct {
// The filter that was matched.
Filter FilterV2 `json:"filter"`
// The keywords within the filter that were matched.
KeywordMatches []string `json:"keyword_matches"`
// The status IDs within the filter that were matched.
StatusMatches []string `json:"status_matches"`
}

View File

@ -0,0 +1,106 @@
// 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 model
// FilterV2 represents a user-defined filter for determining which statuses should not be shown to the user.
// v2 filters have names and can include multiple phrases and status IDs to filter.
//
// swagger:model filterV2
//
// ---
// tags:
// - filters
type FilterV2 struct {
// The ID of the filter in the database.
ID string `json:"id"`
// The name of the filter.
//
// Example: Linux Words
Title string `json:"title"`
// The contexts in which the filter should be applied.
//
// Minimum items: 1
// Unique: true
// Enum:
// - home
// - notifications
// - public
// - thread
// - account
// Example: ["home", "public"]
Context []FilterContext `json:"context"`
// When the filter should no longer be applied. Null if the filter does not expire.
//
// Example: 2024-02-01T02:57:49Z
ExpiresAt *string `json:"expires_at"`
// The action to be taken when a status matches this filter.
// Enum:
// - warn
// - hide
FilterAction FilterAction `json:"filter_action"`
// The keywords grouped under this filter.
Keywords []FilterKeyword `json:"keywords"`
// The statuses grouped under this filter.
Statuses []FilterStatus `json:"statuses"`
}
// FilterAction is the action to apply to statuses matching a filter.
type FilterAction string
const (
// FilterActionNone filters should not exist, except internally, for partially constructed or invalid filters.
FilterActionNone FilterAction = ""
// FilterActionWarn filters will include this status in API results with a warning.
FilterActionWarn FilterAction = "warn"
// FilterActionHide filters will remove this status from API results.
FilterActionHide FilterAction = "hide"
)
// FilterKeyword represents text to filter within a v2 filter.
//
// swagger:model filterKeyword
//
// ---
// tags:
// - filters
type FilterKeyword struct {
// The ID of the filter keyword entry in the database.
ID string `json:"id"`
// The text to be filtered.
//
// Example: fnord
Keyword string `json:"keyword"`
// Should the filter consider word boundaries?
//
// Example: true
WholeWord bool `json:"whole_word"`
}
// FilterStatus represents a single status to filter within a v2 filter.
//
// swagger:model filterStatus
//
// ---
// tags:
// - filters
type FilterStatus struct {
// The ID of the filter status entry in the database.
ID string `json:"id"`
// The status ID to be filtered.
StatusID string `json:"phrase"`
}

View File

@ -100,6 +100,8 @@ type Status struct {
// so the user may redraft from the source text without the client having to reverse-engineer
// the original text from the HTML content.
Text string `json:"text,omitempty"`
// A list of filters that matched this status and why they matched, if there are any such filters.
Filtered []FilterResult `json:"filtered,omitempty"`
// Additional fields not exposed via JSON
// (used only internally for templating etc).

View File

@ -148,15 +148,15 @@ type GTSCaches struct {
// Tag provides access to the gtsmodel Tag database cache.
Tag StructCache[*gtsmodel.Tag]
// ThreadMute provides access to the gtsmodel ThreadMute database cache.
ThreadMute StructCache[*gtsmodel.ThreadMute]
// Token provides access to the gtsmodel Token database cache.
Token StructCache[*gtsmodel.Token]
// Tombstone provides access to the gtsmodel Tombstone database cache.
Tombstone StructCache[*gtsmodel.Tombstone]
// ThreadMute provides access to the gtsmodel ThreadMute database cache.
ThreadMute StructCache[*gtsmodel.ThreadMute]
// User provides access to the gtsmodel User database cache.
User StructCache[*gtsmodel.User]

View File

@ -172,6 +172,8 @@ func totalOfRatios() float64 {
return 0 +
config.GetCacheAccountMemRatio() +
config.GetCacheAccountNoteMemRatio() +
config.GetCacheAccountSettingsMemRatio() +
config.GetCacheAccountStatsMemRatio() +
config.GetCacheApplicationMemRatio() +
config.GetCacheBlockMemRatio() +
config.GetCacheBlockIDsMemRatio() +
@ -179,17 +181,21 @@ func totalOfRatios() float64 {
config.GetCacheClientMemRatio() +
config.GetCacheEmojiMemRatio() +
config.GetCacheEmojiCategoryMemRatio() +
config.GetCacheFilterMemRatio() +
config.GetCacheFilterKeywordMemRatio() +
config.GetCacheFilterStatusMemRatio() +
config.GetCacheFollowMemRatio() +
config.GetCacheFollowIDsMemRatio() +
config.GetCacheFollowRequestMemRatio() +
config.GetCacheFollowRequestIDsMemRatio() +
config.GetCacheInReplyToIDsMemRatio() +
config.GetCacheInstanceMemRatio() +
config.GetCacheInReplyToIDsMemRatio() +
config.GetCacheListMemRatio() +
config.GetCacheListEntryMemRatio() +
config.GetCacheMarkerMemRatio() +
config.GetCacheMediaMemRatio() +
config.GetCacheMentionMemRatio() +
config.GetCacheMoveMemRatio() +
config.GetCacheNotificationMemRatio() +
config.GetCachePollMemRatio() +
config.GetCachePollVoteMemRatio() +

View File

@ -0,0 +1,45 @@
// 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 status represents status filters managed by the user through the API.
package status
import (
"errors"
)
// ErrHideStatus indicates that a status has been filtered and should not be returned at all.
var ErrHideStatus = errors.New("hide status")
// FilterContext determines the filters that apply to a given status or list of statuses.
type FilterContext string
const (
// FilterContextNone means no filters should be applied.
// There are no filters with this context; it's for internal use only.
FilterContextNone FilterContext = ""
// FilterContextHome means this status is being filtered as part of a home or list timeline.
FilterContextHome FilterContext = "home"
// FilterContextNotifications means this status is being filtered as part of the notifications timeline.
FilterContextNotifications FilterContext = "notifications"
// FilterContextPublic means this status is being filtered as part of a public or tag timeline.
FilterContextPublic FilterContext = "public"
// FilterContextThread means this status is being filtered as part of a thread's context.
FilterContextThread FilterContext = "thread"
// FilterContextAccount means this status is being filtered as part of an account's statuses.
FilterContextAccount FilterContext = "account"
)

View File

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -74,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode
}
// Convert the status.
item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil {
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
continue

View File

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -96,9 +97,15 @@ func (p *Processor) StatusesGet(
return nil, gtserror.NewErrorInternalError(err)
}
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
for _, s := range filtered {
// Convert filtered statuses to API statuses.
item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount)
item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue

View File

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -184,7 +185,7 @@ func (p *Processor) GetAPIStatus(
apiStatus *apimodel.Status,
errWithCode gtserror.WithCode,
) {
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
@ -192,87 +193,6 @@ func (p *Processor) GetAPIStatus(
return apiStatus, nil
}
// GetVisibleAPIStatuses converts an array of gtsmodel.Status (inputted by next function) into
// API model statuses, checking first for visibility. Please note that all errors will be
// logged at ERROR level, but will not be returned. Callers are likely to run into show-stopping
// errors in the lead-up to this function, whereas calling this should not be a show-stopper.
func (p *Processor) GetVisibleAPIStatuses(
ctx context.Context,
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []*apimodel.Status {
return p.getVisibleAPIStatuses(ctx, 3, requester, next, length)
}
// GetVisibleAPIStatusesPaged is functionally equivalent to GetVisibleAPIStatuses(),
// except the statuses are returned as a converted slice of statuses as interface{}.
func (p *Processor) GetVisibleAPIStatusesPaged(
ctx context.Context,
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []interface{} {
statuses := p.getVisibleAPIStatuses(ctx, 3, requester, next, length)
if len(statuses) == 0 {
return nil
}
items := make([]interface{}, len(statuses))
for i, status := range statuses {
items[i] = status
}
return items
}
func (p *Processor) getVisibleAPIStatuses(
ctx context.Context,
calldepth int, // used to skip wrapping func above these's names
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []*apimodel.Status {
// Start new log entry with
// the above calling func's name.
l := log.
WithContext(ctx).
WithField("caller", log.Caller(calldepth+1))
// Preallocate slice according to expected length.
statuses := make([]*apimodel.Status, 0, length)
for i := 0; i < length; i++ {
// Get next status.
status := next(i)
if status == nil {
continue
}
// Check whether this status is visible to requesting account.
visible, err := p.filter.StatusVisible(ctx, requester, status)
if err != nil {
l.Errorf("error checking status visibility: %v", err)
continue
}
if !visible {
// Not visible to requester.
continue
}
// Convert the status to an API model representation.
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester)
if err != nil {
l.Errorf("error converting status: %v", err)
continue
}
// Append API model to return slice.
statuses = append(statuses, apiStatus)
}
return statuses
}
// InvalidateTimelinedStatus is a shortcut function for invalidating the cached
// representation one status in the home timeline and all list timelines of the
// given accountID. It should only be called in cases where a status update

View File

@ -21,6 +21,7 @@ import (
"context"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -113,7 +114,7 @@ func (p *Processor) packageStatuses(
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil {
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
continue

View File

@ -23,6 +23,7 @@ import (
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
@ -280,7 +281,15 @@ func TopoSort(apiStatuses []*apimodel.Status, targetAccountID string) {
// ContextGet returns the context (previous and following posts) from the given status ID.
func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
return p.contextGet(ctx, requestingAccount, targetStatusID, p.converter.StatusToAPIStatus)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters)
}
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
}
// WebContextGet is like ContextGet, but is explicitly

View File

@ -24,6 +24,7 @@ import (
"testing"
"github.com/stretchr/testify/suite"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -39,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.NoError(errWithCode)
editedStatus := suite.testStatuses["remote_account_1_status_1"]
apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account)
apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil)
suite.NoError(err)
suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)

View File

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -54,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue

View File

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -98,7 +99,13 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount)
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters)
}
}

View File

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -110,7 +111,13 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount)
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters)
}
}

View File

@ -43,6 +43,12 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
return util.EmptyPageableResponse(), nil
}
filters, err := p.state.DB.GetFiltersForAccountID(ctx, authed.Account.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", authed.Account.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
var (
items = make([]interface{}, 0, count)
nextMaxIDValue string
@ -70,7 +76,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
item, err := p.converter.NotificationToAPINotification(ctx, n)
item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
if err != nil {
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
continue
@ -104,7 +110,13 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
return nil, gtserror.NewErrorNotFound(err)
}
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, account.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", account.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)

View File

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -46,6 +47,16 @@ func (p *Processor) PublicTimelineGet(
items = make([]any, 0, limit)
)
var filters []*gtsmodel.Filter
if requester != nil {
var err error
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
// Try a few times to select appropriate public
// statuses from the db, paging up or down to
// reattempt if nothing suitable is found.
@ -87,7 +98,10 @@ outer:
continue inner
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue inner

View File

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -111,6 +112,12 @@ func (p *Processor) packageTagResponse(
prevMinIDValue = statuses[0].ID
)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
for _, s := range statuses {
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
if err != nil {
@ -122,7 +129,10 @@ func (p *Processor) packageTagResponse(
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue

View File

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/messages"
@ -154,6 +155,8 @@ func (suite *FromClientAPITestSuite) statusJSON(
ctx,
status,
requestingAccount,
statusfilter.FilterContextNone,
nil,
)
if err != nil {
suite.FailNow(err.Error())
@ -258,7 +261,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
suite.FailNow("timed out waiting for new status notification")
}
apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif)
apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil)
if err != nil {
suite.FailNow(err.Error())
}

View File

@ -467,7 +467,12 @@ func (s *Surface) Notify(
unlock()
// Stream notification to the user.
apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif)
filters, err := s.State.DB.GetFiltersForAccountID(ctx, targetAccount.ID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err)
}
apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters)
if err != nil {
return gtserror.Newf("error converting notification to api representation: %w", err)
}

View File

@ -22,6 +22,7 @@ import (
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -111,6 +112,11 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
continue
}
filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusForFollow(
@ -118,6 +124,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
status,
follow,
&errs,
filters,
)
// Add status to home timeline for owner
@ -129,6 +136,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
follow.Account,
status,
stream.TimelineHome,
filters,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@ -180,6 +188,7 @@ func (s *Surface) listTimelineStatusForFollow(
status *gtsmodel.Status,
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@ -222,6 +231,7 @@ func (s *Surface) listTimelineStatusForFollow(
follow.Account,
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@ -332,6 +342,7 @@ func (s *Surface) timelineStatus(
account *gtsmodel.Account,
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
) (bool, error) {
// Ingest status into given timeline using provided function.
if inserted, err := ingest(ctx, timelineID, status); err != nil {
@ -343,7 +354,12 @@ func (s *Surface) timelineStatus(
}
// The status was inserted so stream it to the user.
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account)
apiStatus, err := s.Converter.StatusToAPIStatus(ctx,
status,
account,
statusfilter.FilterContextHome,
filters,
)
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return true, err
@ -457,6 +473,11 @@ func (s *Surface) timelineStatusUpdateForFollowers(
continue
}
filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusUpdateForFollow(
@ -464,6 +485,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
status,
follow,
&errs,
filters,
)
// Add status to home timeline for owner
@ -473,6 +495,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
follow.Account,
status,
stream.TimelineHome,
filters,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@ -490,6 +513,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
status *gtsmodel.Status,
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@ -530,6 +554,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
follow.Account,
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@ -544,8 +569,13 @@ func (s *Surface) timelineStreamStatusUpdate(
account *gtsmodel.Account,
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
) error {
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account)
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters)
if errors.Is(err, statusfilter.ErrHideStatus) {
// Don't put this status in the stream.
return nil
}
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return err

View File

@ -24,6 +24,7 @@ import (
"codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
@ -121,6 +122,12 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID
for e, entry := range toPrepare {
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
if err != nil {
if errors.Is(err, statusfilter.ErrHideStatus) {
// This item has been filtered out by the requesting user's filters.
// Remove it and skip past it.
t.items.data.Remove(e)
continue
}
if errors.Is(err, db.ErrNoEntries) {
// ErrNoEntries means something has been deleted,
// so we'll likely not be able to ever prepare this.

View File

@ -473,16 +473,19 @@ const (
type TypeUtilsTestSuite struct {
suite.Suite
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatues map[string]*gtsmodel.FilterStatus
typeconverter *typeutils.Converter
}
@ -506,6 +509,9 @@ func (suite *TypeUtilsTestSuite) SetupTest() {
suite.testReports = testrig.NewTestReports()
suite.testMentions = testrig.NewTestMentions()
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testFilters = testrig.NewTestFilters()
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
suite.testFilterStatues = testrig.NewTestFilterStatuses()
suite.typeconverter = typeutils.NewConverter(&suite.state)
testrig.StandardDBSetup(suite.db, nil)

View File

@ -22,17 +22,21 @@ import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -684,12 +688,19 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
// (frontend) representation for serialization on the API.
//
// Requesting account can be nil.
//
// Filter context can be the empty string if these statuses are not being filtered.
//
// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error;
// callers need to handle that case by excluding it from results.
func (c *Converter) StatusToAPIStatus(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters)
if err != nil {
return nil, err
}
@ -704,6 +715,142 @@ func (c *Converter) StatusToAPIStatus(
return apiStatus, nil
}
// statusToAPIFilterResults applies filters to a status and returns an API filter result object.
// The result may be nil if no filters matched.
// If the status should not be returned at all, it returns the ErrHideStatus error.
func (c *Converter) statusToAPIFilterResults(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
) ([]apimodel.FilterResult, error) {
if filterContext == "" || len(filters) == 0 || s.AccountID == requestingAccount.ID {
return nil, nil
}
filterResults := make([]apimodel.FilterResult, 0, len(filters))
now := time.Now()
for _, filter := range filters {
if !filterAppliesInContext(filter, filterContext) {
// Filter doesn't apply to this context.
continue
}
if !filter.ExpiresAt.IsZero() && filter.ExpiresAt.Before(now) {
// Filter is expired.
continue
}
// List all matching keywords.
keywordMatches := make([]string, 0, len(filter.Keywords))
fields := filterableTextFields(s)
for _, filterKeyword := range filter.Keywords {
wholeWord := util.PtrValueOr(filterKeyword.WholeWord, false)
wordBreak := ``
if wholeWord {
wordBreak = `\b`
}
re, err := regexp.Compile(`(?i)` + wordBreak + regexp.QuoteMeta(filterKeyword.Keyword) + wordBreak)
if err != nil {
return nil, err
}
var isMatch bool
for _, field := range fields {
if re.MatchString(field) {
isMatch = true
break
}
}
if isMatch {
keywordMatches = append(keywordMatches, filterKeyword.Keyword)
}
}
// A status has only one ID. Not clear why this is a list in the Mastodon API.
statusMatches := make([]string, 0, 1)
for _, filterStatus := range filter.Statuses {
if s.ID == filterStatus.StatusID {
statusMatches = append(statusMatches, filterStatus.StatusID)
break
}
}
if len(keywordMatches) > 0 || len(statusMatches) > 0 {
switch filter.Action {
case gtsmodel.FilterActionWarn:
// Record what matched.
apiFilter, err := c.FilterToAPIFilterV2(ctx, filter)
if err != nil {
return nil, err
}
filterResults = append(filterResults, apimodel.FilterResult{
Filter: *apiFilter,
KeywordMatches: keywordMatches,
StatusMatches: statusMatches,
})
case gtsmodel.FilterActionHide:
// Don't show this status. Immediate return.
return nil, statusfilter.ErrHideStatus
}
}
}
return filterResults, nil
}
// filterableTextFields returns all text from a status that we might want to filter on:
// - content
// - content warning
// - media descriptions
// - poll options
func filterableTextFields(s *gtsmodel.Status) []string {
fieldCount := 2 + len(s.Attachments)
if s.Poll != nil {
fieldCount += len(s.Poll.Options)
}
fields := make([]string, 0, fieldCount)
if s.Content != "" {
fields = append(fields, text.SanitizeToPlaintext(s.Content))
}
if s.ContentWarning != "" {
fields = append(fields, s.ContentWarning)
}
for _, attachment := range s.Attachments {
if attachment.Description != "" {
fields = append(fields, attachment.Description)
}
}
if s.Poll != nil {
for _, option := range s.Poll.Options {
if option != "" {
fields = append(fields, option)
}
}
}
return fields
}
// filterAppliesInContext returns whether a given filter applies in a given context.
func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool {
switch filterContext {
case statusfilter.FilterContextHome:
return util.PtrValueOr(filter.ContextHome, false)
case statusfilter.FilterContextNotifications:
return util.PtrValueOr(filter.ContextNotifications, false)
case statusfilter.FilterContextPublic:
return util.PtrValueOr(filter.ContextPublic, false)
case statusfilter.FilterContextThread:
return util.PtrValueOr(filter.ContextThread, false)
case statusfilter.FilterContextAccount:
return util.PtrValueOr(filter.ContextAccount, false)
}
return false
}
// StatusToWebStatus converts a gts model status into an
// api representation suitable for serving into a web template.
//
@ -713,7 +860,7 @@ func (c *Converter) StatusToWebStatus(
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) {
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil {
return nil, err
}
@ -815,6 +962,8 @@ func (c *Converter) statusToFrontend(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) {
// Try to populate status struct pointer fields.
// We can continue in many cases of partial failure,
@ -913,7 +1062,11 @@ func (c *Converter) statusToFrontend(
}
if s.BoostOf != nil {
reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount)
reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters)
if errors.Is(err, statusfilter.ErrHideStatus) {
// If we'd hide the original status, hide the boost.
return nil, err
}
if err != nil {
return nil, gtserror.Newf("error converting boosted status: %w", err)
}
@ -977,6 +1130,13 @@ func (c *Converter) statusToFrontend(
s.URL = s.URI
}
// Apply filters.
filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters)
if err != nil {
return nil, fmt.Errorf("error applying filters: %w", err)
}
apiStatus.Filtered = filterResults
return apiStatus, nil
}
@ -1252,7 +1412,7 @@ func (c *Converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod
}
// NotificationToAPINotification converts a gts notification into a api notification
func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) {
func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter) (*apimodel.Notification, error) {
if n.TargetAccount == nil {
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
if err != nil {
@ -1293,7 +1453,7 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
}
var err error
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount)
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err)
}
@ -1446,7 +1606,7 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
}
}
for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount)
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
@ -1687,6 +1847,55 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
}
filter := filterKeyword.Filter
return &apimodel.FilterV1{
// v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID.
ID: filterKeyword.ID,
Phrase: filterKeyword.Keyword,
Context: filterToAPIFilterContexts(filter),
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
Irreversible: filter.Action == gtsmodel.FilterActionHide,
}, nil
}
// FilterToAPIFilterV2 converts one GTS model filter into an API v2 filter.
func (c *Converter) FilterToAPIFilterV2(ctx context.Context, filter *gtsmodel.Filter) (*apimodel.FilterV2, error) {
apiFilterKeywords := make([]apimodel.FilterKeyword, 0, len(filter.Keywords))
for _, filterKeyword := range filter.Keywords {
apiFilterKeywords = append(apiFilterKeywords, apimodel.FilterKeyword{
ID: filterKeyword.ID,
Keyword: filterKeyword.Keyword,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
})
}
apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords))
for _, filterStatus := range filter.Statuses {
apiFilterStatuses = append(apiFilterStatuses, apimodel.FilterStatus{
ID: filterStatus.ID,
StatusID: filterStatus.StatusID,
})
}
return &apimodel.FilterV2{
ID: filter.ID,
Title: filter.Title,
Context: filterToAPIFilterContexts(filter),
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
FilterAction: filterActionToAPIFilterAction(filter.Action),
Keywords: apiFilterKeywords,
Statuses: apiFilterStatuses,
}, nil
}
func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string {
if expiresAt.IsZero() {
return nil
}
return util.Ptr(util.FormatISO8601(expiresAt))
}
func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
if util.PtrValueOr(filter.ContextHome, false) {
apiContexts = append(apiContexts, apimodel.FilterContextHome)
@ -1703,21 +1912,17 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
if util.PtrValueOr(filter.ContextAccount, false) {
apiContexts = append(apiContexts, apimodel.FilterContextAccount)
}
return apiContexts
}
var expiresAt *string
if !filter.ExpiresAt.IsZero() {
expiresAt = util.Ptr(util.FormatISO8601(filter.ExpiresAt))
func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterAction {
switch m {
case gtsmodel.FilterActionWarn:
return apimodel.FilterActionWarn
case gtsmodel.FilterActionHide:
return apimodel.FilterActionHide
}
return &apimodel.FilterV1{
// v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID.
ID: filterKeyword.ID,
Phrase: filterKeyword.Keyword,
Context: apiContexts,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
ExpiresAt: expiresAt,
Irreversible: filter.Action == gtsmodel.FilterActionHide,
}, nil
return apimodel.FilterActionNone
}
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.

View File

@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@ -427,7 +428,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_1"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@ -537,11 +538,186 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
}`, string(b))
}
// Test that a status which is filtered with a warn filter by the requesting user has `filtered` set correctly.
func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
testStatus.Content += " fnord"
testStatus.Text += " fnord"
requestingAccount := suite.testAccounts["local_account_1"]
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextHome,
requestingAccountFilters,
)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01F8MH75CBF9JFX4ZAD54N0W0R",
"created_at": "2021-10-20T11:36:45.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"language": "en",
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
"reblogged": false,
"muted": false,
"bookmarked": true,
"pinned": false,
"content": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": {
"name": "admin"
}
},
"media_attachments": [
{
"id": "01F8MH6NEM8D7527KZAECTCR76",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg",
"remote_url": null,
"preview_remote_url": null,
"meta": {
"original": {
"width": 1200,
"height": 630,
"size": "1200x630",
"aspect": 1.9047619
},
"small": {
"width": 256,
"height": 134,
"size": "256x134",
"aspect": 1.9104477
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "Black and white image of some 50's style text saying: Welcome On Board",
"blurhash": "LNJRdVM{00Rj%Mayt7j[4nWBofRj"
}
],
"mentions": [],
"tags": [
{
"name": "welcome",
"url": "http://localhost:8080/tags/welcome"
}
],
"emojis": [
{
"shortcode": "rainbow",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"visible_in_picker": true,
"category": "reactions"
}
],
"card": null,
"poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"filtered": [
{
"filter": {
"id": "01HN26VM6KZTW1ANNRVSBMA461",
"title": "fnord",
"context": [
"home",
"public"
],
"expires_at": null,
"filter_action": "warn",
"keywords": [
{
"id": "01HN272TAVWAXX72ZX4M8JZ0PS",
"keyword": "fnord",
"whole_word": true
}
],
"statuses": []
},
"keyword_matches": [
"fnord"
],
"status_matches": []
}
]
}`, string(b))
}
// Test that a status which is filtered with a hide filter by the requesting user results in the ErrHideStatus error.
func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
testStatus.Content += " fnord"
testStatus.Text += " fnord"
requestingAccount := suite.testAccounts["local_account_1"]
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
expectedMatchingFilter.Action = gtsmodel.FilterActionHide
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
_, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextHome,
requestingAccountFilters,
)
suite.ErrorIs(err, statusfilter.ErrHideStatus)
}
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() {
testStatus := suite.testStatuses["remote_account_2_status_1"]
requestingAccount := suite.testAccounts["admin_account"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@ -774,7 +950,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
*testStatus = *suite.testStatuses["admin_account_status_1"]
testStatus.Language = ""
requestingAccount := suite.testAccounts["local_account_1"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")

View File

@ -556,6 +556,12 @@ func (c *Cache[T]) Cap() int {
func (c *Cache[T]) store_value(index *Index, key Key, value T) {
// Alloc new index item.
item := new_indexed_item()
if cap(item.indexed) < len(c.indices) {
// Preallocate item indices slice to prevent Go auto
// allocating overlying large slices we don't need.
item.indexed = make([]*index_entry, 0, len(c.indices))
}
// Create COPY of value.
value = c.copy(value)
@ -622,6 +628,14 @@ func (c *Cache[T]) store_error(index *Index, key Key, err error) {
// Alloc new index item.
item := new_indexed_item()
if cap(item.indexed) < len(c.indices) {
// Preallocate item indices slice to prevent Go auto
// allocating overlying large slices we don't need.
item.indexed = make([]*index_entry, 0, len(c.indices))
}
// Set error val.
item.data = err
// Append item to index.

View File

@ -51,8 +51,12 @@ func (i *indexed_item) drop_index(entry *index_entry) {
continue
}
// Unset tptr value to
// ensure GC can take it.
i.indexed[x] = nil
// Move all index entries down + reslice.
copy(i.indexed[x:], i.indexed[x+1:])
_ = copy(i.indexed[x:], i.indexed[x+1:])
i.indexed = i.indexed[:len(i.indexed)-1]
break
}

View File

@ -276,6 +276,12 @@ func (q *Queue[T]) pop_n(n int, next func() *list_elem) []T {
func (q *Queue[T]) index(value T) *indexed_item {
item := new_indexed_item()
if cap(item.indexed) < len(q.indices) {
// Preallocate item indices slice to prevent Go auto
// allocating overlying large slices we don't need.
item.indexed = make([]*index_entry, 0, len(q.indices))
}
// Set item value.
item.data = value

View File

@ -393,7 +393,7 @@ func ReuseTokenSource(t *Token, src TokenSource) TokenSource {
}
}
// ReuseTokenSource returns a TokenSource that acts in the same manner as the
// ReuseTokenSourceWithExpiry returns a TokenSource that acts in the same manner as the
// TokenSource returned by ReuseTokenSource, except the expiry buffer is
// configurable. The expiration time of a token is calculated as
// t.Expiry.Add(-earlyExpiry).

4
vendor/modules.txt vendored
View File

@ -62,7 +62,7 @@ codeberg.org/gruf/go-sched
## explicit; go 1.19
codeberg.org/gruf/go-store/v2/storage
codeberg.org/gruf/go-store/v2/util
# codeberg.org/gruf/go-structr v0.8.0
# codeberg.org/gruf/go-structr v0.8.2
## explicit; go 1.21
codeberg.org/gruf/go-structr
# codeberg.org/superseriousbusiness/exif-terminator v0.7.0
@ -1056,7 +1056,7 @@ golang.org/x/net/ipv6
golang.org/x/net/proxy
golang.org/x/net/publicsuffix
golang.org/x/net/trace
# golang.org/x/oauth2 v0.19.0
# golang.org/x/oauth2 v0.20.0
## explicit; go 1.18
golang.org/x/oauth2
golang.org/x/oauth2/internal

View File

@ -17,7 +17,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import React, { ReactNode } from "react";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
@ -44,39 +46,70 @@ function ErrorFallback({ error, resetErrorBoundary }) {
);
}
function Error({ error }) {
/* eslint-disable-next-line no-console */
console.error("Rendering error:", error);
let message;
interface GtsError {
/**
* Error message returned from the API.
*/
error: string;
if (error.data != undefined) { // RTK Query error with data
if (error.status) {
message = (<>
<b>{error.status}:</b> {error.data.error}
{error.data.error_description &&
<p>
{error.data.error_description}
</p>
}
</>);
} else {
message = error.data.error;
}
} else if (error.name != undefined || error.type != undefined) { // JS error
message = (<>
<b>{error.type && error.name}:</b> {error.message}
</>);
} else if (error.status && typeof error.error == "string") {
message = (<>
<b>{error.status}:</b> {error.error}
</>);
/**
* For OAuth errors: description of the error.
*/
error_description?: string;
}
interface ErrorProps {
error: FetchBaseQueryError | SerializedError | Error | undefined;
/**
* Optional function to clear the error.
* If provided, rendered error will have
* a "dismiss" button.
*/
reset?: () => void;
}
function Error({ error, reset }: ErrorProps) {
if (error === undefined) {
return null;
}
/* eslint-disable-next-line no-console */
console.error("caught error: ", error);
let message: ReactNode;
if ("status" in error) {
// RTK Query error with data.
const gtsError = error.data as GtsError;
const errMsg = gtsError.error_description ?? gtsError.error;
message = <>Code {error.status} {errMsg}</>;
} else {
message = error.message ?? error;
// SerializedError or Error.
const errMsg = error.message ?? JSON.stringify(error);
message = (
<>{error.name && `${error.name}: `}{errMsg}</>
);
}
let className = "error";
if (reset) {
className += " with-dismiss";
}
return (
<div className="error">
{message}
<div className={className}>
<span>{message}</span>
{ reset &&
<span
className="dismiss"
onClick={reset}
role="button"
tabIndex={0}
>
<span>Dismiss</span>
<i className="fa fa-fw fa-close" aria-hidden="true" />
</span>
}
</div>
);
}

View File

@ -29,6 +29,7 @@ import type {
RadioFormInputHook,
TextFormInputHook,
} from "../../lib/form/types";
import { nanoid } from "nanoid";
export interface TextInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
@ -92,22 +93,25 @@ export interface FileInputProps extends React.DetailedHTMLProps<
export function FileInput({ label, field, ...props }: FileInputProps) {
const { onChange, ref, infoComponent } = field;
const id = nanoid();
return (
<div className="form-field file">
<label>
<div className="label">{label}</div>
<div className="file-input button">Browse</div>
{infoComponent}
{/* <a onClick={removeFile("header")}>remove</a> */}
<input
type="file"
className="hidden"
onChange={onChange}
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
{...props}
/>
<label className="label-label" htmlFor={id}>
{label}
</label>
<label className="label-button" htmlFor={id}>
<div className="file-input button">Browse</div>
</label>
<input
id={id}
type="file"
className="hidden"
onChange={onChange}
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
{...props}
/>
{infoComponent}
</div>
);
}

View File

@ -51,9 +51,9 @@ export default function MutationButton({
}
return (
<div className={wrapperClassName}>
<div className={wrapperClassName ? wrapperClassName : "mutation-button"}>
{(showError && targetsThisButton && result.error) &&
<Error error={result.error} />
<Error error={result.error} reset={result.reset} />
}
<button
type="submit"

View File

@ -27,6 +27,7 @@ import type {
HookOpts,
FileFormInputHook,
} from "./types";
import { Error as ErrorC } from "../../components/error";
const _default = undefined;
export default function useFileInput(
@ -41,6 +42,15 @@ export default function useFileInput(
const [imageURL, setImageURL] = useState<string>();
const [info, setInfo] = useState<React.JSX.Element>();
function reset() {
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
setImageURL(undefined);
setFile(undefined);
setInfo(undefined);
}
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files) {
@ -59,25 +69,18 @@ export default function useFileInput(
setImageURL(URL.createObjectURL(file));
}
let size = prettierBytes(file.size);
const sizePrettier = prettierBytes(file.size);
if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>;
const maxSizePrettier = prettierBytes(maxSize);
setInfo(
<ErrorC
error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)}
reset={(reset)}
/>
);
} else {
setInfo(<>{file.name} ({sizePrettier})</>);
}
setInfo(
<>
{file.name} ({size})
</>
);
}
function reset() {
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
setImageURL(undefined);
setFile(undefined);
setInfo(undefined);
}
const infoComponent = (

View File

@ -257,33 +257,37 @@ input, select, textarea {
overflow: auto;
margin: 0;
}
&.with-dismiss {
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
align-items: center;
flex-wrap: wrap;
align-items: center;
flex-wrap: wrap;
.dismiss {
display: flex;
flex-shrink: 0;
align-items: center;
align-self: stretch;
gap: 0.25rem;
}
}
}
.mutation-button {
display: flex;
flex-direction: column;
gap: 1rem;
}
.hidden {
display: none;
}
.messagebutton, .messagebutton > div {
display: flex;
align-items: center;
flex-wrap: wrap;
div.padded {
margin-left: 1rem;
}
button, .button {
white-space: nowrap;
margin-right: 1rem;
}
}
.messagebutton > div {
button, .button {
margin-top: 1rem;
}
}
.notImplemented {
border: 2px solid rgb(70, 79, 88);
background: repeating-linear-gradient(
@ -500,12 +504,29 @@ form {
font-weight: bold;
}
.form-field.file label {
.form-field.file {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"label-label label-label"
"label-button file-info"
;
.label-label {
grid-area: label-label;
}
.label {
grid-column: 1 / span 2;
.label-button {
grid-area: label-button;
}
.form-info {
grid-area: file-info;
.error {
padding: 0.1rem;
line-height: 1.4rem;
}
}
}

View File

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo, useEffect } from "react";
import React, { useMemo, useEffect, ReactNode } from "react";
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit";
@ -27,52 +27,74 @@ import FakeToot from "../../../../components/fake-toot";
import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
import prettierBytes from "prettier-bytes";
export default function NewEmojiForm() {
const shortcode = useShortcode();
const { data: instance } = useInstanceV1Query();
const emojiMaxSize = useMemo(() => {
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
}, [instance]);
const image = useFileInput("image", {
withPreview: true,
maxSize: emojiMaxSize
});
const prettierMaxSize = useMemo(() => {
return prettierBytes(emojiMaxSize);
}, [emojiMaxSize]);
const category = useComboBoxInput("category");
const form = {
shortcode: useShortcode(),
image: useFileInput("image", {
withPreview: true,
maxSize: emojiMaxSize
}),
category: useComboBoxInput("category"),
};
const [submitForm, result] = useFormSubmit({
shortcode, image, category
}, useAddEmojiMutation());
const [submitForm, result] = useFormSubmit(
form,
useAddEmojiMutation(),
{
changedOnly: false,
// On submission, reset form values
// no matter what the result was.
onFinish: (_res) => {
form.shortcode.reset();
form.image.reset();
form.category.reset();
}
},
);
useEffect(() => {
if (shortcode.value === undefined || shortcode.value.length == 0) {
if (image.value != undefined) {
let [name, _ext] = image.value.name.split(".");
shortcode.setter(name);
}
// If shortcode has not been entered yet, but an image file
// has been submitted, suggest a shortcode based on filename.
if (
(form.shortcode.value === undefined || form.shortcode.value.length === 0) &&
form.image.value !== undefined
) {
let [name, _ext] = form.image.value.name.split(".");
form.shortcode.setter(name);
}
/* We explicitly don't want to have 'shortcode' as a dependency here
because we only want to change the shortcode to the filename if the field is empty
at the moment the file is selected, not some time after when the field is emptied
*/
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [image.value]);
// We explicitly don't want to have 'shortcode' as a
// dependency here because we only want to change the
// shortcode to the filename if the field is empty at
// the moment the file is selected, not some time after
// when the field is emptied.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.image.value]);
let emojiOrShortcode;
if (image.previewValue != undefined) {
emojiOrShortcode = <img
className="emoji"
src={image.previewValue}
title={`:${shortcode.value}:`}
alt={shortcode.value}
/>;
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
emojiOrShortcode = `:${shortcode.value}:`;
let emojiOrShortcode: ReactNode;
if (form.image.previewValue !== undefined) {
emojiOrShortcode = (
<img
className="emoji"
src={form.image.previewValue}
title={`:${form.shortcode.value}:`}
alt={form.shortcode.value}
/>
);
} else if (form.shortcode.value !== undefined && form.shortcode.value.length > 0) {
emojiOrShortcode = `:${form.shortcode.value}:`;
} else {
emojiOrShortcode = `:your_emoji_here:`;
}
@ -87,22 +109,23 @@ export default function NewEmojiForm() {
<form onSubmit={submitForm} className="form-flex">
<FileInput
field={image}
field={form.image}
label={`Image file: png, gif, or static webp; max size ${prettierMaxSize}`}
accept="image/png,image/gif,image/webp"
/>
<TextInput
field={shortcode}
field={form.shortcode}
label="Shortcode, must be unique among the instance's local emoji"
{...{pattern: "^\\w{2,30}$"}}
/>
<CategorySelect
field={category}
children={[]}
field={form.category}
/>
<MutationButton
disabled={image.previewValue === undefined}
disabled={form.image.previewValue === undefined || form.shortcode.value?.length === 0}
label="Upload emoji"
result={result}
/>