Merge branch 'main' into feature/sqlite-optimizations

This commit is contained in:
tsmethurst 2022-12-13 13:17:02 +01:00
commit 2c3b79ac7a
43 changed files with 1507 additions and 192 deletions

View file

@ -3209,6 +3209,40 @@ paths:
summary: Clean up remote media older than the specified number of days.
tags:
- admin
/api/v1/admin/media_refetch:
post:
description: |-
Currently, this only includes remote emojis.
This endpoint is useful when data loss has occurred, and you want to try to recover to a working state.
operationId: mediaRefetch
parameters:
- description: Domain to refetch media from. If empty, all domains will be refetched.
in: query
name: domain
type: string
produces:
- application/json
responses:
"202":
description: Request accepted and will be processed. Check the logs for progress / errors.
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Refetch media specified in the database but missing from storage.
tags:
- admin
/api/v1/apps:
post:
consumes:

View file

@ -256,3 +256,132 @@ or Systemd service file to enable the profile.
If SELinux is available on your system, you can optionally install [SELinux
policy](https://github.com/lzap/gotosocial-selinux) to further improve security.
## nginx
This section contains a number of additional things for configuring nginx.
### Extra Hardening
If you want to harden up your NGINX deployment with advanced configuration options, there are many guides online for doing so ([for example](https://beaglesecurity.com/blog/article/nginx-server-security.html)). Try to find one that's up to date. Mozilla also publishes best-practice ssl configuration [here](https://ssl-config.mozilla.org/).
### Caching Webfinger
It's possible to use nginx to cache the webfinger responses. This may be useful in order to ensure clients still get a response on the webfinger endpoint even if GTS is (temporarily) down.
You'll need to configure two things:
* A cache path
* An additional `location` block for webfinger
First, the cache path which needs to happen in the `http` section, usually inside your `nginx.conf`:
```nginx.conf
http {
... there will be other things here ...
proxy_cache_path /var/cache/nginx keys_zone=ap_webfinger:10m inactive=1w;
}
```
This configures a cache of 10MB whose entries will be kept up to one week if they're not accessed. The zone is named `ap_webfinger` but you can name it whatever you want. 10MB is a lot of cache keys, you can probably use a much smaller value on small instances.
Second, actually use the cache for webfinger:
```nginx.conf
server {
server_name example.org;
location /.well-known/webfinger {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache ap_webfinger;
proxy_cache_background_update on;
proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_429;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
}
location / {
proxy_pass http://localhost:8080/;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
client_max_body_size 40M;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
```
The `proxy_pass` and `proxy_set_header` are mostly the same, but the `proxy_cache*` entries warrant some explanation:
* `proxy_cache ap_webfinger` tells it to use the `ap_webfinger` cache zone we previously created. If you named it something else, you should change this value
* `proxy_cache_background_update on` means nginx will try and refresh a cached resource that's about to expire in the background, to ensure it has a current copy on disk
* `proxy_cache_key` is configured in such a way that it takes the query string into account for caching. So a request for `.well-known/webfinger?acct=user1@example.org` and `.well-known/webfinger?acct=user2@example.org` are not seen as the same
* `proxy_cache_valid 200 10m;` means we only cache 200 responses from GTS and for 10 minutes. You can add additional lines of these, like `proxy_cache_valid 404 1m;` to cache 404 responses for 1 minute
* `proxy_cache_use_stale` tells nginx it's allowed to use a stale cache entry (so older than 10 minutes) in certain cases
* `proxy_cache_lock on` means that if a resource is not cached and there's multiple concurrent requests for them, the queries will be queued up so that only one request goes through and the rest is then answered from cache
* `add_header X-Cache-Status $upstream_cache_status` will add an `X-Cache-Status` header to the response so you can check if things are getting cached. You can remove this.
Tweaking `proxy_cache_use_stale` is how you can ensure webfinger responses are still answered even if GTS itself is down. The provided configuration will serve a stale response in case there's an error proxying to GTS, if our connection to GTS times out, if GTS returns a 5xx status code or if GTS returns 429 (Too Many Requests). The `updating` value says that we're allowed to serve a stale entry if nginx is currently in the process of refreshing its cache. Because we configured `inactive=1w` in the `proxy_cache_path` directive, nginx may serve a response up to one week old if the conditions in `proxy_cache_use_stale` are met.
### Serving static assets
By default, GTS will serve assets like the CSS and fonts for the web UI as well as attachments for statuses. However it's very simple to have nginx do this instead and offload GTS from that responsibility. Nginx can generally do a faster job at this too since it's able to use newer functionality in the OS that the Go runtime hasn't necessarily adopted yet.
There are 2 paths that nginx can handle for us:
* `/assets` which contains fonts, CSS, images etc. for the web UI
* `/fileserver` which serves attachments for status posts when using the local storage backend
For `/assets` we'll need the value of `web-asset-base-dir` from the configuration, and for `/fileserver` we'll want `storage-local-base-path`. You can then adjust your nginx configuration like this:
```nginx.conf
server {
server_name example.org;
location /assets/ {
alias web-asset-base-dir/;
autoindex off;
expires 5m;
add_header Cache-Control "public";
}
location /fileserver/ {
alias storage-local-base-path/;
autoindex off;
expires max;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://localhost:8080/;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
client_max_body_size 40M;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
```
The trailing slashes in the new `location` directives and the `alias` are significant, do not remove those. The `expires` directive adds the necessary headers to inform the client how long it may cache the resource. For assets, which may change on each release, 5 minutes is used in this example. For attachments, which should never change once they're created, `max` is used instead setting the cache expiry to the 31st of December 2037. For other options, see the nginx documentation on the [`expires` directive](https://nginx.org/en/docs/http/ngx_http_headers_module.html#expires). Nginx does not add cache headers to 4xx or 5xx response codes so a failure to fetch an asset won't get cached by clients. The `autoindex off` directive tells nginx to not serve a directory listing. This should be the default but it doesn't hurt to be explicit. The added `add_header` lines set additional options for the `Cache-Control` header:
* `public` is used to indicate that anyone may cache this resource
* `immutable` is used to indicate this resource will never change while it is fresh (it's before the end of the expires) allowing clients to forego conditional requests to revalidate the resource during that timespan

View file

@ -181,6 +181,4 @@ server {
}
```
## Extra Hardening
If you want to harden up your NGINX deployment with advanced configuration options, there are many guides online for doing so ([for example](https://beaglesecurity.com/blog/article/nginx-server-security.html)). Try to find one that's up to date. Mozilla also publishes best-practice ssl configuration [here](https://ssl-config.mozilla.org/).
A number of additional configurations for nginx, including static asset serving and caching, are documented in the [Advanced](advanced.md) section of our documentation.

View file

@ -234,6 +234,61 @@ cache:
user-ttl: "5m"
user-sweep-freq: "10s"
cache:
gts:
###########################
#### DATABASE CACHES ######
###########################
#
# Database cache configuration:
#
# Allows configuration of caches used
# when loading GTS models from the database.
#
# max-size = maximum cached objects count
# ttl = cached object lifetime
# sweep-freq = frequency to look for stale cache objects
account-max-size: 100
account-ttl: "5m"
account-sweep-freq: "10s"
block-max-size: 100
block-ttl: "5m"
block-sweep-freq: "10s"
domain-block-max-size: 1000
domain-block-ttl: "24h"
domain-block-sweep-freq: "1m"
emoji-max-size: 500
emoji-ttl: "5m"
emoji-sweep-freq: "10s"
emoji-category-max-size: 100
emoji-category-ttl: "5m"
emoji-category-sweep-freq: "10s"
mention-max-size: 500
mention-ttl: "5m"
mention-sweep-freq: "10s"
notification-max-size: 500
notification-ttl: "5m"
notification-sweep-freq: "10s"
status-max-size: 500
status-ttl: "5m"
status-sweep-freq: "10s"
tombstone-max-size: 100
tombstone-ttl: "5m"
tombstone-sweep-freq: "10s"
user-max-size: 100
user-ttl: "5m"
user-sweep-freq: "10s"
######################
##### WEB CONFIG #####
######################

4
go.mod
View file

@ -50,10 +50,10 @@ require (
github.com/wagslane/go-password-validator v0.3.0
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
golang.org/x/image v0.1.0
golang.org/x/image v0.2.0
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783
golang.org/x/text v0.4.0
golang.org/x/text v0.5.0
gopkg.in/mcuadros/go-syslog.v2 v2.3.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.19.5

8
go.sum
View file

@ -700,8 +700,8 @@ golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2F
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -912,8 +912,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

View file

@ -46,6 +46,7 @@ const (
// AccountsActionPath is used for taking action on a single account.
AccountsActionPath = AccountsPathWithID + "/action"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
// ExportQueryKey is for requesting a public export of some data.
ExportQueryKey = "export"
@ -63,6 +64,8 @@ const (
MinShortcodeDomainKey = "min_shortcode_domain"
// LimitKey is for specifying maximum number of results to return.
LimitKey = "limit"
// DomainQueryKey is for specifying a domain during admin actions.
DomainQueryKey = "domain"
)
// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
@ -90,6 +93,7 @@ func (m *Module) Route(r router.Router) error {
r.AttachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler)
r.AttachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)
r.AttachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler)
r.AttachHandler(http.MethodPost, MediaRefetchPath, m.MediaRefetchPOSTHandler)
r.AttachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler)
return nil
}

View file

@ -0,0 +1,93 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// MediaRefetchPOSTHandler swagger:operation POST /api/v1/admin/media_refetch mediaRefetch
//
// Refetch media specified in the database but missing from storage.
// Currently, this only includes remote emojis.
// This endpoint is useful when data loss has occurred, and you want to try to recover to a working state.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - admin
//
// parameters:
// -
// name: domain
// in: query
// description: >-
// Domain to refetch media from.
// If empty, all domains will be refetched.
// type: string
//
// responses:
// '202':
// description: >-
// Request accepted and will be processed.
// Check the logs for progress / errors.
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) MediaRefetchPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet)
return
}
if errWithCode := m.processor.AdminMediaRefetch(c.Request.Context(), authed, c.Query(DomainQueryKey)); errWithCode != nil {
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.Status(http.StatusAccepted)
}

View file

@ -57,8 +57,6 @@ func (m *mediaDB) GetRemoteOlderThan(ctx context.Context, olderThan time.Time, l
NewSelect().
Model(&attachments).
Where("? = ?", bun.Ident("media_attachment.cached"), true).
Where("? = ?", bun.Ident("media_attachment.avatar"), false).
Where("? = ?", bun.Ident("media_attachment.header"), false).
Where("? < ?", bun.Ident("media_attachment.created_at"), olderThan).
WhereGroup(" AND ", whereNotEmptyAndNotNull("media_attachment.remote_url")).
Order("media_attachment.created_at DESC")
@ -70,6 +68,7 @@ func (m *mediaDB) GetRemoteOlderThan(ctx context.Context, olderThan time.Time, l
if err := q.Scan(ctx); err != nil {
return nil, m.conn.ProcessError(err)
}
return attachments, nil
}

View file

@ -41,7 +41,7 @@ func (suite *MediaTestSuite) TestGetAttachmentByID() {
func (suite *MediaTestSuite) TestGetOlder() {
attachments, err := suite.db.GetRemoteOlderThan(context.Background(), time.Now(), 20)
suite.NoError(err)
suite.Len(attachments, 2)
suite.Len(attachments, 3)
}
func (suite *MediaTestSuite) TestGetAvisAndHeaders() {
@ -49,7 +49,7 @@ func (suite *MediaTestSuite) TestGetAvisAndHeaders() {
attachments, err := suite.db.GetAvatarsAndHeaders(ctx, "", 20)
suite.NoError(err)
suite.Len(attachments, 2)
suite.Len(attachments, 3)
}
func (suite *MediaTestSuite) TestGetLocalUnattachedOlderThan() {

View file

@ -29,18 +29,20 @@ import (
type Media interface {
// GetAttachmentByID gets a single attachment by its ID
GetAttachmentByID(ctx context.Context, id string) (*gtsmodel.MediaAttachment, Error)
// GetRemoteOlderThan gets limit n remote media attachments older than the given olderThan time.
// These will be returned in order of attachment.created_at descending (newest to oldest in other words).
// GetRemoteOlderThan gets limit n remote media attachments (including avatars and headers) older than the given
// olderThan time. These will be returned in order of attachment.created_at descending (newest to oldest in other words).
//
// The selected media attachments will be those with both a URL and a RemoteURL filled in.
// In other words, media attachments that originated remotely, and that we currently have cached locally.
GetRemoteOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, Error)
// GetAvatarsAndHeaders fetches limit n avatars and headers with an id < maxID. These headers
// and avis may be in use or not; the caller should check this if it's important.
GetAvatarsAndHeaders(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, Error)
// GetLocalUnattachedOlderThan fetches limit n local media attachments, older than the given time, which
// aren't header or avatars, and aren't attached to a status. In other words, attachments which were uploaded
// but never used for whatever reason, or attachments that were attached to a status which was subsequently
// deleted.
// GetLocalUnattachedOlderThan fetches limit n local media attachments (including avatars and headers), older than
// the given time, which aren't header or avatars, and aren't attached to a status. In other words, attachments which were
// uploaded but never used for whatever reason, or attachments that were attached to a status which was subsequently deleted.
GetLocalUnattachedOlderThan(ctx context.Context, olderThan time.Time, maxID string, limit int) ([]*gtsmodel.MediaAttachment, Error)
}

View file

@ -40,6 +40,15 @@ const UnusedLocalAttachmentCacheDays = 3
// Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs.
type Manager interface {
// Stop stops the underlying worker pool of the manager. It should be called
// when closing GoToSocial in order to cleanly finish any in-progress jobs.
// It will block until workers are finished processing.
Stop() error
/*
PROCESSING FUNCTIONS
*/
// ProcessMedia begins the process of decoding and storing the given data as an attachment.
// It will return a pointer to a ProcessingMedia struct upon which further actions can be performed, such as getting
// the finished media, thumbnail, attachment, etc.
@ -75,6 +84,10 @@ type Manager interface {
// RecacheMedia refetches, reprocesses, and recaches an existing attachment that has been uncached via pruneRemote.
RecacheMedia(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, attachmentID string) (*ProcessingMedia, error)
/*
PRUNING FUNCTIONS
*/
// PruneAllRemote prunes all remote media attachments cached on this instance which are older than the given amount of days.
// 'Pruning' in this context means removing the locally stored data of the attachment (both thumbnail and full size),
// and setting 'cached' to false on the associated attachment.
@ -98,10 +111,18 @@ type Manager interface {
// is returned to the caller.
PruneOrphaned(ctx context.Context, dry bool) (int, error)
// Stop stops the underlying worker pool of the manager. It should be called
// when closing GoToSocial in order to cleanly finish any in-progress jobs.
// It will block until workers are finished processing.
Stop() error
/*
REFETCHING FUNCTIONS
Useful when data loss has occurred.
*/
// RefetchEmojis iterates through remote emojis (for the given domain, or all if domain is empty string).
//
// For each emoji, the manager will check whether both the full size and static images are present in storage.
// If not, the manager will refetch and reprocess full size and static images for the emoji.
//
// The provided DereferenceMedia function will be used when it's necessary to refetch something this way.
RefetchEmojis(ctx context.Context, domain string, dereferenceMedia DereferenceMedia) (int, error)
}
type manager struct {

View file

@ -20,22 +20,26 @@ package media_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/concurrency"
"github.com/superseriousbusiness/gotosocial/internal/db"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type MediaStandardTestSuite struct {
suite.Suite
db db.DB
storage *storage.Driver
manager media.Manager
testAttachments map[string]*gtsmodel.MediaAttachment
testAccounts map[string]*gtsmodel.Account
testEmojis map[string]*gtsmodel.Emoji
db db.DB
storage *storage.Driver
manager media.Manager
transportController transport.Controller
testAttachments map[string]*gtsmodel.MediaAttachment
testAccounts map[string]*gtsmodel.Account
testEmojis map[string]*gtsmodel.Emoji
}
func (suite *MediaStandardTestSuite) SetupSuite() {
@ -53,6 +57,7 @@ func (suite *MediaStandardTestSuite) SetupTest() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testEmojis = testrig.NewTestEmojis()
suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage)
suite.transportController = testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../testrig/media"), suite.db, concurrency.NewWorkerPool[messages.FromFederator](0, 0))
}
func (suite *MediaStandardTestSuite) TearDownTest() {

View file

@ -81,10 +81,8 @@ func (p *ProcessingMedia) AttachmentID() string {
// LoadAttachment blocks until the thumbnail and fullsize content
// has been processed, and then returns the completed attachment.
func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) {
log.Tracef("LoadAttachment: getting lock for attachment %s", p.attachment.URL)
p.mu.Lock()
defer p.mu.Unlock()
log.Tracef("LoadAttachment: got lock for attachment %s", p.attachment.URL)
if err := p.store(ctx); err != nil {
return nil, err
@ -98,23 +96,24 @@ func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAt
return nil, err
}
// store the result in the database before returning it
if !p.insertedInDB {
if p.recache {
// if it's a recache we should only need to update
// This is an existing media attachment we're recaching, so only need to update it
if err := p.database.UpdateByID(ctx, p.attachment, p.attachment.ID); err != nil {
return nil, err
}
} else {
// otherwise we need to really PUT it
// This is a new media attachment we're caching for first time
if err := p.database.Put(ctx, p.attachment); err != nil {
return nil, err
}
}
// Mark this as stored in DB
p.insertedInDB = true
}
log.Tracef("LoadAttachment: finished, returning attachment %s", p.attachment.URL)
log.Tracef("finished loading attachment %s", p.attachment.URL)
return p.attachment, nil
}
@ -180,7 +179,7 @@ func (p *ProcessingMedia) loadThumb(ctx context.Context) error {
// we're done processing the thumbnail!
atomic.StoreInt32(&p.thumbState, int32(complete))
log.Tracef("loadThumb: finished processing thumbnail for attachment %s", p.attachment.URL)
log.Tracef("finished processing thumbnail for attachment %s", p.attachment.URL)
fallthrough
case complete:
return nil
@ -241,7 +240,7 @@ func (p *ProcessingMedia) loadFullSize(ctx context.Context) error {
// we're done processing the full-size image
atomic.StoreInt32(&p.fullSizeState, int32(complete))
log.Tracef("loadFullSize: finished processing full size image for attachment %s", p.attachment.URL)
log.Tracef("finished processing full size image for attachment %s", p.attachment.URL)
fallthrough
case complete:
return nil
@ -362,7 +361,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
p.attachment.File.FileSize = int(fileSize)
p.read = true
log.Tracef("store: finished storing initial data for attachment %s", p.attachment.URL)
log.Tracef("finished storing initial data for attachment %s", p.attachment.URL)
return nil
}

View file

@ -20,6 +20,7 @@ package media
import (
"context"
"errors"
"codeberg.org/gruf/go-store/v2/storage"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -28,17 +29,23 @@ import (
)
func (m *manager) PruneAllMeta(ctx context.Context) (int, error) {
var totalPruned int
var maxID string
var attachments []*gtsmodel.MediaAttachment
var err error
var (
totalPruned int
maxID string
)
for {
// select "selectPruneLimit" headers / avatars at a time for pruning
attachments, err := m.db.GetAvatarsAndHeaders(ctx, maxID, selectPruneLimit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return totalPruned, err
} else if len(attachments) == 0 {
break
}
// select 20 attachments at a time and prune them
for attachments, err = m.db.GetAvatarsAndHeaders(ctx, maxID, selectPruneLimit); err == nil && len(attachments) != 0; attachments, err = m.db.GetAvatarsAndHeaders(ctx, maxID, selectPruneLimit) {
// use the id of the last attachment in the slice as the next 'maxID' value
l := len(attachments)
log.Tracef("PruneAllMeta: got %d attachments with maxID < %s", l, maxID)
maxID = attachments[l-1].ID
log.Tracef("PruneAllMeta: got %d attachments with maxID < %s", len(attachments), maxID)
maxID = attachments[len(attachments)-1].ID
// prune each attachment that meets one of the following criteria:
// - has no owning account in the database
@ -56,11 +63,6 @@ func (m *manager) PruneAllMeta(ctx context.Context) (int, error) {
}
}
// make sure we don't have a real error when we leave the loop
if err != nil && err != db.ErrNoEntries {
return totalPruned, err
}
log.Infof("PruneAllMeta: finished pruning avatars + headers: pruned %d entries", totalPruned)
return totalPruned, nil
}

View file

@ -20,7 +20,8 @@ package media
import (
"context"
"fmt"
"errors"
"time"
"codeberg.org/gruf/go-store/v2/storage"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -31,21 +32,23 @@ import (
func (m *manager) PruneAllRemote(ctx context.Context, olderThanDays int) (int, error) {
var totalPruned int
olderThan, err := parseOlderThan(olderThanDays)
if err != nil {
return totalPruned, fmt.Errorf("PruneAllRemote: error parsing olderThanDays %d: %s", olderThanDays, err)
}
olderThan := time.Now().Add(-time.Hour * 24 * time.Duration(olderThanDays))
log.Infof("PruneAllRemote: pruning media older than %s", olderThan)
// select 20 attachments at a time and prune them
for attachments, err := m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit); err == nil && len(attachments) != 0; attachments, err = m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit) {
for {
// Select "selectPruneLimit" status attacchments at a time for pruning
attachments, err := m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return totalPruned, err
} else if len(attachments) == 0 {
break
}
// use the age of the oldest attachment (the last one in the slice) as the next 'older than' value
l := len(attachments)
log.Tracef("PruneAllRemote: got %d attachments older than %s", l, olderThan)
olderThan = attachments[l-1].CreatedAt
// use the age of the oldest attachment (last in slice) as the next 'olderThan' value
log.Tracef("PruneAllRemote: got %d status attachments older than %s", len(attachments), olderThan)
olderThan = attachments[len(attachments)-1].CreatedAt
// prune each attachment
// prune each status attachment
for _, attachment := range attachments {
if err := m.pruneOneRemote(ctx, attachment); err != nil {
return totalPruned, err
@ -54,11 +57,6 @@ func (m *manager) PruneAllRemote(ctx context.Context, olderThanDays int) (int, e
}
}
// make sure we don't have a real error when we leave the loop
if err != nil && err != db.ErrNoEntries {
return totalPruned, err
}
log.Infof("PruneAllRemote: finished pruning remote media: pruned %d entries", totalPruned)
return totalPruned, nil
}
@ -69,7 +67,7 @@ func (m *manager) pruneOneRemote(ctx context.Context, attachment *gtsmodel.Media
if attachment.File.Path != "" {
// delete the full size attachment from storage
log.Tracef("pruneOneRemote: deleting %s", attachment.File.Path)
if err := m.storage.Delete(ctx, attachment.File.Path); err != nil && err != storage.ErrNotFound {
if err := m.storage.Delete(ctx, attachment.File.Path); err != nil && !errors.Is(err, storage.ErrNotFound) {
return err
}
cached := false
@ -80,7 +78,7 @@ func (m *manager) pruneOneRemote(ctx context.Context, attachment *gtsmodel.Media
if attachment.Thumbnail.Path != "" {
// delete the thumbnail from storage
log.Tracef("pruneOneRemote: deleting %s", attachment.Thumbnail.Path)
if err := m.storage.Delete(ctx, attachment.Thumbnail.Path); err != nil && err != storage.ErrNotFound {
if err := m.storage.Delete(ctx, attachment.Thumbnail.Path); err != nil && !errors.Is(err, storage.ErrNotFound) {
return err
}
cached := false
@ -88,10 +86,10 @@ func (m *manager) pruneOneRemote(ctx context.Context, attachment *gtsmodel.Media
changed = true
}
// update the attachment to reflect that we no longer have it cached
if changed {
return m.db.UpdateByID(ctx, attachment, attachment.ID, "updated_at", "cached")
if !changed {
return nil
}
return nil
// update the attachment to reflect that we no longer have it cached
return m.db.UpdateByID(ctx, attachment, attachment.ID, "updated_at", "cached")
}

View file

@ -27,6 +27,7 @@ import (
"codeberg.org/gruf/go-store/v2/storage"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type PruneRemoteTestSuite struct {
@ -34,24 +35,29 @@ type PruneRemoteTestSuite struct {
}
func (suite *PruneRemoteTestSuite) TestPruneRemote() {
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testAttachment.Cached)
testStatusAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testStatusAttachment.Cached)
testHeader := suite.testAttachments["remote_account_3_header"]
suite.True(*testHeader.Cached)
totalPruned, err := suite.manager.PruneAllRemote(context.Background(), 1)
suite.NoError(err)
suite.Equal(2, totalPruned)
suite.Equal(3, totalPruned)
prunedAttachment, err := suite.db.GetAttachmentByID(context.Background(), testAttachment.ID)
prunedAttachment, err := suite.db.GetAttachmentByID(context.Background(), testStatusAttachment.ID)
suite.NoError(err)
suite.False(*prunedAttachment.Cached)
// the media should no longer be cached
prunedAttachment, err = suite.db.GetAttachmentByID(context.Background(), testHeader.ID)
suite.NoError(err)
suite.False(*prunedAttachment.Cached)
}
func (suite *PruneRemoteTestSuite) TestPruneRemoteTwice() {
totalPruned, err := suite.manager.PruneAllRemote(context.Background(), 1)
suite.NoError(err)
suite.Equal(2, totalPruned)
suite.Equal(3, totalPruned)
// final prune should prune nothing, since the first prune already happened
totalPrunedAgain, err := suite.manager.PruneAllRemote(context.Background(), 1)
@ -61,16 +67,21 @@ func (suite *PruneRemoteTestSuite) TestPruneRemoteTwice() {
func (suite *PruneRemoteTestSuite) TestPruneAndRecache() {
ctx := context.Background()
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
testStatusAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
testHeader := suite.testAttachments["remote_account_3_header"]
totalPruned, err := suite.manager.PruneAllRemote(ctx, 1)
suite.NoError(err)
suite.Equal(2, totalPruned)
suite.Equal(3, totalPruned)
// media should no longer be stored
_, err = suite.storage.Get(ctx, testAttachment.File.Path)
_, err = suite.storage.Get(ctx, testStatusAttachment.File.Path)
suite.ErrorIs(err, storage.ErrNotFound)
_, err = suite.storage.Get(ctx, testAttachment.Thumbnail.Path)
_, err = suite.storage.Get(ctx, testStatusAttachment.Thumbnail.Path)
suite.ErrorIs(err, storage.ErrNotFound)
_, err = suite.storage.Get(ctx, testHeader.File.Path)
suite.ErrorIs(err, storage.ErrNotFound)
_, err = suite.storage.Get(ctx, testHeader.Thumbnail.Path)
suite.ErrorIs(err, storage.ErrNotFound)
// now recache the image....
@ -82,34 +93,40 @@ func (suite *PruneRemoteTestSuite) TestPruneAndRecache() {
}
return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
}
processingRecache, err := suite.manager.RecacheMedia(ctx, data, nil, testAttachment.ID)
suite.NoError(err)
// synchronously load the recached attachment
recachedAttachment, err := processingRecache.LoadAttachment(ctx)
suite.NoError(err)
suite.NotNil(recachedAttachment)
for _, original := range []*gtsmodel.MediaAttachment{
testStatusAttachment,
testHeader,
} {
processingRecache, err := suite.manager.RecacheMedia(ctx, data, nil, original.ID)
suite.NoError(err)
// recachedAttachment should be basically the same as the old attachment
suite.True(*recachedAttachment.Cached)
suite.Equal(testAttachment.ID, recachedAttachment.ID)
suite.Equal(testAttachment.File.Path, recachedAttachment.File.Path) // file should be stored in the same place
suite.Equal(testAttachment.Thumbnail.Path, recachedAttachment.Thumbnail.Path) // as should the thumbnail
suite.EqualValues(testAttachment.FileMeta, recachedAttachment.FileMeta) // and the filemeta should be the same
// synchronously load the recached attachment
recachedAttachment, err := processingRecache.LoadAttachment(ctx)
suite.NoError(err)
suite.NotNil(recachedAttachment)
// recached files should be back in storage
_, err = suite.storage.Get(ctx, recachedAttachment.File.Path)
suite.NoError(err)
_, err = suite.storage.Get(ctx, recachedAttachment.Thumbnail.Path)
suite.NoError(err)
// recachedAttachment should be basically the same as the old attachment
suite.True(*recachedAttachment.Cached)
suite.Equal(original.ID, recachedAttachment.ID)
suite.Equal(original.File.Path, recachedAttachment.File.Path) // file should be stored in the same place
suite.Equal(original.Thumbnail.Path, recachedAttachment.Thumbnail.Path) // as should the thumbnail
suite.EqualValues(original.FileMeta, recachedAttachment.FileMeta) // and the filemeta should be the same
// recached files should be back in storage
_, err = suite.storage.Get(ctx, recachedAttachment.File.Path)
suite.NoError(err)
_, err = suite.storage.Get(ctx, recachedAttachment.Thumbnail.Path)
suite.NoError(err)
}
}
func (suite *PruneRemoteTestSuite) TestPruneOneNonExistent() {
ctx := context.Background()
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
testStatusAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
// Delete this attachment cached on disk
media, err := suite.db.GetAttachmentByID(ctx, testAttachment.ID)
media, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
suite.True(*media.Cached)
err = suite.storage.Delete(ctx, media.File.Path)
@ -118,7 +135,7 @@ func (suite *PruneRemoteTestSuite) TestPruneOneNonExistent() {
// Now attempt to prune remote for item with db entry no file
totalPruned, err := suite.manager.PruneAllRemote(ctx, 1)
suite.NoError(err)
suite.Equal(2, totalPruned)
suite.Equal(3, totalPruned)
}
func TestPruneRemoteTestSuite(t *testing.T) {

View file

@ -20,7 +20,7 @@ package media
import (
"context"
"fmt"
"time"
"codeberg.org/gruf/go-store/v2/storage"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -34,10 +34,7 @@ func (m *manager) PruneUnusedLocalAttachments(ctx context.Context) (int, error)
var attachments []*gtsmodel.MediaAttachment
var err error
olderThan, err := parseOlderThan(UnusedLocalAttachmentCacheDays)
if err != nil {
return totalPruned, fmt.Errorf("PruneUnusedLocalAttachments: error parsing olderThanDays %d: %s", UnusedLocalAttachmentCacheDays, err)
}
olderThan := time.Now().Add(-time.Hour * 24 * time.Duration(UnusedLocalAttachmentCacheDays))
log.Infof("PruneUnusedLocalAttachments: pruning unused local attachments older than %s", olderThan)
// select 20 attachments at a time and prune them

149
internal/media/refetch.go Normal file
View file

@ -0,0 +1,149 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 media
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
type DereferenceMedia func(ctx context.Context, iri *url.URL) (io.ReadCloser, int64, error)
func (m *manager) RefetchEmojis(ctx context.Context, domain string, dereferenceMedia DereferenceMedia) (int, error) {
// normalize domain
if domain == "" {
domain = db.EmojiAllDomains
}
var (
maxShortcodeDomain string
refetchIDs []string
)
// page through emojis 20 at a time, looking for those with missing images
for {
// Fetch next block of emojis from database
emojis, err := m.db.GetEmojis(ctx, domain, false, true, "", maxShortcodeDomain, "", 20)
if err != nil {
if !errors.Is(err, db.ErrNoEntries) {
// an actual error has occurred
log.Errorf("error fetching emojis from database: %s", err)
}
break
}
for _, emoji := range emojis {
if emoji.Domain == "" {
// never try to refetch local emojis
continue
}
if refetch, err := m.emojiRequiresRefetch(ctx, emoji); err != nil {
// an error here indicates something is wrong with storage, so we should stop
return 0, fmt.Errorf("error checking refetch requirement for emoji %s: %w", util.ShortcodeDomain(emoji), err)
} else if !refetch {
continue
}
refetchIDs = append(refetchIDs, emoji.ID)
}
// Update next maxShortcodeDomain from last emoji
maxShortcodeDomain = util.ShortcodeDomain(emojis[len(emojis)-1])
}
// bail early if we've got nothing to do
toRefetchCount := len(refetchIDs)
if toRefetchCount == 0 {
log.Debug("no remote emojis require a refetch")
return 0, nil
}
log.Debugf("%d remote emoji(s) require a refetch, doing that now...", toRefetchCount)
var totalRefetched int
for _, emojiID := range refetchIDs {
emoji, err := m.db.GetEmojiByID(ctx, emojiID)
if err != nil {
// this shouldn't happen--since we know we have the emoji--so return if it does
return 0, fmt.Errorf("error getting emoji %s: %w", emojiID, err)
}
shortcodeDomain := util.ShortcodeDomain(emoji)
if emoji.ImageRemoteURL == "" {
log.Errorf("remote emoji %s could not be refreshed because it has no ImageRemoteURL set", shortcodeDomain)
continue
}
emojiImageIRI, err := url.Parse(emoji.ImageRemoteURL)
if err != nil {
log.Errorf("remote emoji %s could not be refreshed because its ImageRemoteURL (%s) is not a valid uri: %s", shortcodeDomain, emoji.ImageRemoteURL, err)
continue
}
dataFunc := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) {
return dereferenceMedia(ctx, emojiImageIRI)
}
processingEmoji, err := m.ProcessEmoji(ctx, dataFunc, nil, emoji.Shortcode, emoji.ID, emoji.URI, &AdditionalEmojiInfo{
Domain: &emoji.Domain,
ImageRemoteURL: &emoji.ImageRemoteURL,
ImageStaticRemoteURL: &emoji.ImageStaticRemoteURL,
Disabled: emoji.Disabled,
VisibleInPicker: emoji.VisibleInPicker,
}, true)
if err != nil {
log.Errorf("emoji %s could not be refreshed because of an error during processing: %s", shortcodeDomain, err)
continue
}
if _, err := processingEmoji.LoadEmoji(ctx); err != nil {
log.Errorf("emoji %s could not be refreshed because of an error during loading: %s", shortcodeDomain, err)
continue
}
log.Tracef("refetched emoji %s successfully from remote", shortcodeDomain)
totalRefetched++
}
return totalRefetched, nil
}
func (m *manager) emojiRequiresRefetch(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) {
if has, err := m.storage.Has(ctx, emoji.ImagePath); err != nil {
return false, err
} else if !has {
return true, nil
}
if has, err := m.storage.Has(ctx, emoji.ImageStaticPath); err != nil {
return false, err
} else if !has {
return true, nil
}
return false, nil
}

View file

@ -0,0 +1,85 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 media_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
)
type RefetchTestSuite struct {
MediaStandardTestSuite
}
func (suite *RefetchTestSuite) TestRefetchEmojisNothingToDo() {
ctx := context.Background()
adminAccount := suite.testAccounts["admin_account"]
transport, err := suite.transportController.NewTransportForUsername(ctx, adminAccount.Username)
if err != nil {
suite.FailNow(err.Error())
}
refetched, err := suite.manager.RefetchEmojis(ctx, "", transport.DereferenceMedia)
suite.NoError(err)
suite.Equal(0, refetched)
}
func (suite *RefetchTestSuite) TestRefetchEmojis() {
ctx := context.Background()
if err := suite.storage.Delete(ctx, suite.testEmojis["yell"].ImagePath); err != nil {
suite.FailNow(err.Error())
}
adminAccount := suite.testAccounts["admin_account"]
transport, err := suite.transportController.NewTransportForUsername(ctx, adminAccount.Username)
if err != nil {
suite.FailNow(err.Error())
}
refetched, err := suite.manager.RefetchEmojis(ctx, "", transport.DereferenceMedia)
suite.NoError(err)
suite.Equal(1, refetched)
}
func (suite *RefetchTestSuite) TestRefetchEmojisLocal() {
ctx := context.Background()
// delete the image for a LOCAL emoji
if err := suite.storage.Delete(ctx, suite.testEmojis["rainbow"].ImagePath); err != nil {
suite.FailNow(err.Error())
}
adminAccount := suite.testAccounts["admin_account"]
transport, err := suite.transportController.NewTransportForUsername(ctx, adminAccount.Username)
if err != nil {
suite.FailNow(err.Error())
}
refetched, err := suite.manager.RefetchEmojis(ctx, "", transport.DereferenceMedia)
suite.NoError(err)
suite.Equal(0, refetched) // shouldn't refetch anything because local
}
func TestRefetchTestSuite(t *testing.T) {
suite.Run(t, &RefetchTestSuite{})
}

View file

@ -23,7 +23,6 @@ import (
"errors"
"fmt"
"io"
"time"
"github.com/h2non/filetype"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -134,22 +133,6 @@ func (l *logrusWrapper) Error(err error, msg string, keysAndValues ...interface{
log.Error("media manager cron logger: ", err, msg, keysAndValues)
}
func parseOlderThan(olderThanDays int) (time.Time, error) {
// convert days into a duration string
olderThanHoursString := fmt.Sprintf("%dh", olderThanDays*24)
// parse the duration string into a duration
olderThanHours, err := time.ParseDuration(olderThanHoursString)
if err != nil {
return time.Time{}, err
}
// 'subtract' that from the time now to give our threshold
olderThan := time.Now().Add(-olderThanHours)
return olderThan, nil
}
// lengthReader wraps a reader and reads the length of total bytes written as it goes.
type lengthReader struct {
source io.Reader

View file

@ -77,3 +77,7 @@ func (p *processor) AdminDomainBlockDelete(ctx context.Context, authed *oauth.Au
func (p *processor) AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode {
return p.adminProcessor.MediaPrune(ctx, mediaRemoteCacheDays)
}
func (p *processor) AdminMediaRefetch(ctx context.Context, authed *oauth.Auth, domain string) gtserror.WithCode {
return p.adminProcessor.MediaRefetch(ctx, authed.Account, domain)
}

View file

@ -30,6 +30,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -48,23 +49,26 @@ type Processor interface {
EmojiUpdate(ctx context.Context, id string, form *apimodel.EmojiUpdateRequest) (*apimodel.AdminEmoji, gtserror.WithCode)
EmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode)
MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode
}
type processor struct {
tc typeutils.TypeConverter
mediaManager media.Manager
storage *storage.Driver
clientWorker *concurrency.WorkerPool[messages.FromClientAPI]
db db.DB
tc typeutils.TypeConverter
mediaManager media.Manager
transportController transport.Controller
storage *storage.Driver
clientWorker *concurrency.WorkerPool[messages.FromClientAPI]
db db.DB
}
// New returns a new admin processor.
func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, storage *storage.Driver, clientWorker *concurrency.WorkerPool[messages.FromClientAPI]) Processor {
func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, transportController transport.Controller, storage *storage.Driver, clientWorker *concurrency.WorkerPool[messages.FromClientAPI]) Processor {
return &processor{
tc: tc,
mediaManager: mediaManager,
storage: storage,
clientWorker: clientWorker,
db: db,
tc: tc,
mediaManager: mediaManager,
transportController: transportController,
storage: storage,
clientWorker: clientWorker,
db: db,
}
}

View file

@ -88,14 +88,10 @@ func (p *processor) EmojisGet(ctx context.Context, account *gtsmodel.Account, us
Items: items,
Path: "api/v1/admin/custom_emojis",
NextMaxIDKey: "max_shortcode_domain",
NextMaxIDValue: shortcodeDomain(emojis[count-1]),
NextMaxIDValue: util.ShortcodeDomain(emojis[count-1]),
PrevMinIDKey: "min_shortcode_domain",
PrevMinIDValue: shortcodeDomain(emojis[0]),
PrevMinIDValue: util.ShortcodeDomain(emojis[0]),
Limit: limit,
ExtraQueryParams: []string{filterBuilder.String()},
})
}
func shortcodeDomain(emoji *gtsmodel.Emoji) string {
return emoji.Shortcode + "@" + emoji.Domain
}

View file

@ -0,0 +1,48 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"context"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
func (p *processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode {
transport, err := p.transportController.NewTransportForUsername(ctx, requestingAccount.Username)
if err != nil {
err = fmt.Errorf("error getting transport for user %s during media refetch request: %w", requestingAccount.Username, err)
return gtserror.NewErrorInternalError(err)
}
go func() {
log.Info("starting emoji refetch")
refetched, err := p.mediaManager.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia)
if err != nil {
log.Errorf("error refetching emojis: %s", err)
} else {
log.Infof("refetched %d emojis from remote", refetched)
}
}()
return nil
}

View file

@ -136,6 +136,8 @@ type Processor interface {
AdminDomainBlockDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.DomainBlock, gtserror.WithCode)
// AdminMediaRemotePrune triggers a prune of remote media according to the given number of mediaRemoteCacheDays
AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
// AdminMediaRefetch triggers a refetch of remote media for the given domain (or all if domain is empty).
AdminMediaRefetch(ctx context.Context, authed *oauth.Auth, domain string) gtserror.WithCode
// AppCreate processes the creation of a new API application
AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode)
@ -318,7 +320,7 @@ func NewProcessor(
statusProcessor := status.New(db, tc, clientWorker, parseMentionFunc)
streamingProcessor := streaming.New(db, oauthServer)
accountProcessor := account.New(db, tc, mediaManager, oauthServer, clientWorker, federator, parseMentionFunc)
adminProcessor := admin.New(db, tc, mediaManager, storage, clientWorker)
adminProcessor := admin.New(db, tc, mediaManager, federator.TransportController(), storage, clientWorker)
mediaProcessor := mediaProcessor.New(db, tc, mediaManager, federator.TransportController(), storage)
userProcessor := user.New(db, emailSender)
federationProcessor := federationProcessor.New(db, tc, federator)

26
internal/util/emoji.go Normal file
View file

@ -0,0 +1,26 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 util
import "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
// ShortcodeDomain returns the [shortcode]@[domain] for the given emoji.
func ShortcodeDomain(emoji *gtsmodel.Emoji) string {
return emoji.Shortcode + "@" + emoji.Domain
}

View file

@ -573,6 +573,43 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
HideCollections: FalseBool(),
SuspensionOrigin: "",
},
"remote_account_3": {
ID: "062G5WYKY35KKD12EMSM3F8PJ8",
Username: "her_fuckin_maj",
Domain: "thequeenisstillalive.technology",
DisplayName: "lizzzieeeeeeeeeeee",
Fields: []gtsmodel.Field{},
Note: "if i die blame charles don't let that fuck become king",
Memorial: FalseBool(),
MovedToAccountID: "",
CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: FalseBool(),
Locked: TrueBool(),
Discoverable: TrueBool(),
Sensitive: FalseBool(),
Language: "en",
URI: "http://thequeenisstillalive.technology/users/her_fuckin_maj",
URL: "http://thequeenisstillalive.technology/@her_fuckin_maj",
LastWebfingeredAt: time.Time{},
InboxURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/inbox",
SharedInboxURI: StringPtr(""),
OutboxURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/outbox",
FollowersURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/followers",
FollowingURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/following",
FeaturedCollectionURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/collections/featured",
ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
HideCollections: FalseBool(),
SuspensionOrigin: "",
HeaderMediaAttachmentID: "01PFPMWK2FF0D9WMHEJHR07C3R",
},
}
var accountsSorted []*gtsmodel.Account
@ -585,6 +622,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
})
preserializedKeys := []string{
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDA3bAoQMUofndXMXikEU2MOJbfI1uaZbIDrxW0bEO6IOhwe/J0jWJHL2fWc2mbp2NxAH4Db1kZIcl9D0owoRf2cT5k0Y2Dah86dGz4fIedkqGryoWAnEJ2hHKGXGQf2K9OS2L8eaDLGU4CBds0m80vrn153Uiyj7zxWDYqcySM0qQjSg+mvgqpBcxKpd+xACaWNDL8qWvDsBF1D0RuO8hUiXMIKOUoFAGbqe6qWGK0COrEYQTAMydoFuSaAccP70zKQslnSOCKvsOi/iPRKGDNqWINIC/lwqXEIpMj3K+b/A+x41zR7frTgHNLbe4yHWAVNPEwTFningbB/lIyyVmDAgMBAAECggEBALxwnipmRnyvPClMY+RiJ5PGwtqYcGsly82/pwRW98GHX7Rv1lA8x/ZnghxNPbVg0k9ZvMXcaICeu4BejQ2AiKo4sU7OVGc/K+3wTXxoKBU0bJQuV0x24JVuCXvwD7/x9i8Yh0nKCOoH+mkNkcUQKWXaJi0IoXwd5u0kVCAbym1vux/9DcwtydqT4P1EoxEHCXDuRorBP8vYWCZBwRY2etmdAEbHsVpVlNlXWfbGCNMf5e8AecOZre4No8UfTOZkM7YKgjryde3YCmY2zDQI9jExGD2L5nptLizODD5imdpp/IQ7qg6rR3XbIK6CDiKiePEFQibD8XWiz7XVD6JBRokCgYEA0jEAxZseHUyobh1ERHezs2vC2zbiTOfnOpFxhwtNt67dUQZDssTxXF+BymUL8yKi1bnheOTuyASxrgZ7BPdiFvJfhlelSxtxtt1RamY58E179uiel2NPRsR3SL2AsGg+jP+QjJpsJHvYIliXP38G7NVaqaSMFgXfXir7Ty7W0r0CgYEA6uYQWfjmaB66xPrL/oCBaJ+UWM/Zdfw4IETVnRVOxVqGE7AKqC+31fZQ5kIXnNcJNLJ0OJlhGH5vZYp/r4z6qly9BUVolCJcW2YLEOOnChOvKGwlDSXrdGty2f34RXdABwsf/pBHsdpJq70+SE01tTB/8P2NTnRafy9GL/FnwT8CgYEAjJ4D6i8wImHafHBP7441Rl9daNJ66wBqDSCoVrQVNkFiBoauW7at0iKC7ihTqkENtvY4BW0C4gVh6Q6k1lm54agch/+ysWCW3sOJaCkjscPknvZYwubJboqZUqyUn2/eCO4ggi/9ERtZKQEjjnMo6uCBWuSeY01iddlDb2HijfECgYBYQCM4ikiWKaVlyAvIDCOSWRH04/IBX8b+aJ4QrCayAraIwwTd9z+MBUSTnZUdebSdtcXwVb+i4i2b6pLaM48hXkItrswBi39DX20c5UqmgIq4Fxk8fVienpfByqbyAkFt5AIbM72b1jUDbs/tfgSFlDkdI0VpilFNo0ctT/b5JQKBgAxPGtVGzhSQUZWPXjhiBT7MM/1EiLBYhGVrymzd9dmBxj+UyifnRXfIQbOQm3EfI5Z8ZpyS6eqWdi9NTeZi8rg0WleMb/VbOMT3xvTO34vDXvwrQKhFMimX1tY7aKy1udnE2ON2/alq2zWo3zPZfYH1KFdDtGD08GW2M4OO1caa",
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGj2wLnDIHnP6wjJ+WmIhp7NGAaKWwfxBWfdMFR+Y0ilkK5ld5igT45UHAmzN3v4HcwHGGpPITD9caDYj5YaGOX+dSdGLgXWwItR0j+ivrHEJmvz8hG6z9wKEZKUUrRw7Ob72S0LOsreq98bjdiWJKHNka27slqQjGyhLQtcg6pe1CLJtnuJH4GEMLj7jJB3/Mqv3vl5CQZ+Js0bXfgw5TF/x/Bzq/8qsxQ1vnmYHJsR0eLPEuDJOvoFPiJZytI09S7qBEJL5PDeVSfjQi3o71sqOzZlEL0b0Ny48rfo/mwJAdkmfcnydRDxeGUEqpAWICCOdUL0+W3/fCffaRZsk1AgMBAAECggEAUuyO6QJgeoF8dGsmMxSc0/ANRp1tpRpLznNZ77ipUYP9z+mG2sFjdjb4kOHASuB18aWFRAAbAQ76fGzuqYe2muk+iFcG/EDH35MUCnRuZxA0QwjX6pHOW2NZZFKyCnLwohJUj74Na65ufMk4tXysydrmaKsfq4i+m5bE6NkiOCtbXsjUGVdJKzkT6X1gEyEPEHgrgVZz9OpRY5nwjZBMcFI6EibFnWdehcuCQLESIX9ll/QzGvTJ1p8xeVJs2ktLWKQ38RewwucNYVLVJmxS1LCPP8x+yHVkOxD66eIncY26sjX+VbyICkaG/ZjKBuoOekOq/T+b6q5ESxWUNfcu+QKBgQDmt3WVBrW6EXKtN1MrVyBoSfn9WHyf8Rfb84t5iNtaWGSyPZK/arUw1DRbI0TdPjct//wMWoUU2/uqcPSzudTaPena3oxjKReXso1hcynHqboCaXJMxWSqDQLumbrVY05C1WFSyhRY0iQS5fIrNzD4+6rmeC2Aj5DKNW5Atda8dwKBgQDcUdhQfjL9SmzzIeAqJUBIfSSI2pSTsZrnrvMtSMkYJbzwYrUdhIVxaS4hXuQYmGgwonLctyvJxVxEMnf+U0nqPgJHE9nGQb5BbK6/LqxBWRJQlc+W6EYodIwvtE5B4JNkPE5757u+xlDdHe2zGUGXSIf4IjBNbSpCu6RcFsGOswKBgEnr4gqbmcJCMOH65fTu930yppxbq6J7Vs+sWrXX+aAazjilrc0S3XcFprjEth3E/10HtbQnlJg4W4wioOSs19wNFk6AG67xzZNXLCFbCrnkUarQKkUawcQSYywbqVcReFPFlmc2RAqpWdGMR2k9R72etQUe4EVeul9veyHUoTbFAoGBAKj3J9NLhaVVb8ri3vzThsJRHzTJlYrTeb5XIO5I1NhtEMK2oLobiQ+aH6O+F2Z5c+Zgn4CABdf/QSyYHAhzLcu0dKC4K5rtjpC0XiwHClovimk9C3BrgGrEP0LSn/XL2p3T1kkWRpkflKKPsl1ZcEEqggSdi7fFkdSN/ZYWaakbAoGBALWVGpA/vXmaZEV/hTDdtDnIHj6RXfKHCsfnyI7AdjUX4gokzdcEvFsEIoI+nnXR/PIAvwqvQw4wiUqQnp2VB8r73YZvW/0npnsidQw3ZjqnyvZ9X8y80nYs7DjSlaG0A8huy2TUdFnJyCMWby30g82kf0b/lhotJg4d3fIDou51",
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6q61hiC7OhlMz7JNnLiL/RwOaFC8955GDvwSMH9Zw3oguWH9nLqkmlJ98cnqRG9ZC0qVo6Gagl7gv6yOHDwD4xZI8JoV2ZfNdDzq4QzoBIzMtRsbSS4IvrF3JP+kDH1tim+CbRMBxiFJgLgS6yeeQlLNvBW+CIYzmeCimZ6CWCr91rZPIprUIdjvhxrM9EQU072Pmzn2gpGM6K5gAReN+LtP+VSBC61x7GQJxBaJNtk11PXkgG99EdFi9vvgEBbM9bdcawvf8jxvjgsgdaDx/1cypDdnaL8eistmyv1YI67bKvrSPCEh55b90hl3o3vW4W5G4gcABoyORON96Y+i9AgMBAAECggEBAKp+tyNH0QiMo13fjFpHR2vFnsKSAPwXj063nx2kzqXUeqlp5yOE+LXmNSzjGpOCy1XJM474BRRUvsP1jkODLq4JNiF+RZP4Vij/CfDWZho33jxSUrIsiUGluxtfJiHV+A++s4zdZK/NhP+XyHYah0gEqUaTvl8q6Zhu0yH5sDCZHDLxDBpgiT5qD3lli8/o2xzzBdaibZdjQyHi9v5Yi3+ysly1tmfmqnkXSsevAubwJu504WxvDUSo7hPpG4a8Xb8ODqL738GIF2UY/olCcGkWqTQEr2pOqG9XbMmlUWnxG62GCfK6KtGfIzCyBBkGO2PZa9aPhVnv2bkYxI4PkLkCgYEAzAp7xH88UbSX31suDRa4jZwgtzhJLeyc3YxO5C4XyWZ89oWrA30V1KvfVwFRavYRJW07a+r0moba+0E1Nj5yZVXPOVu0bWd9ZyMbdH2L6MRZoJWU5bUOwyruulRCkqASZbWo4G05NOVesOyY1bhZGE7RyUW0vOo8tSyyRQ8nUGMCgYEA6jTQbDry4QkUP9tDhvc8+LsobIF1mPLEJui+mT98+9IGar6oeVDKekmNDO0Dx2+miLfjMNhCb5qUc8g036ZsekHt2WuQKunADua0coB00CebMdr6AQFf7QOQ/RuA+/gPJ5G0GzWB3YOQ5gE88tTCO/jBfmikVOZvLtgXUGjo3F8CgYEAl2poMoehQZjc41mMsRXdWukztgPE+pmORzKqENbLvB+cOG01XV9j5fCtyqklvFRioP2QjSNM5aeRtcbMMDbjOaQWJaCSImYcP39kDmxkeRXM1UhruJNGIzsm8Ys55Al53ZSTgAhN3Z0hSfYp7N/i7hD/yXc7Cr5g0qoamPkH2bUCgYApf0oeoyM9tDoeRl9knpHzEFZNQ3LusrUGn96FkLY4eDIi371CIYp+uGGBlM1CnQnI16wtj2PWGnGLQkH8DqTR1LSr/V8B+4DIIyB92TzZVOsunjoFy5SPjj42WpU0D/O/cxWSbJyh/xnBZx7Bd+kibyT5nNjhIiM5DZiz6qK3yQKBgAOO/MFKHKpKOXrtafbqCyculG/ope2u4eBveHKO6ByWcUSbuD9ebtr7Lu5AC5tKUJLkSyRx4EHk71bqP1yOITj8z9wQWdVyLxtVtyj9SUkUNvGwIj+F7NJ5VgHzWVZtvYWDCzrfxkEhKk3DRIIVjqmEohJcaOZoZ2Q/f8sjlId6",
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1NzommDoutE+FVAbgovPb5ioRRS1k93hhH5Mpe4KfAQb1k0aGA/TjrFr2HcRbOtldo6Fe+RRCAm5Go+sBx829zyMEdXbGtR4Pym78xYoRpsCwD+2AK/edQLjsdDf9zXZod9ig/Pe59awYaeuSFyK/w9q94ncuiE7m+MKfXJnTS/qiwwkxWIRm9lBprPIT0DwXCtoh7FdpsOmjLu2QdGADV+9KSDgV5IbVcxwjPY03vHJS4UAIP5eS46TtSrNF3hyM9Q8vGIPAixOVyAY53cRQUxZWU/FIaNjaEgpreUQfK1pgr0gxh1K7IKwmyF3f/JgL0urFljYp2UonzRU5XKHJAgMBAAECggEBAKVT2HLDqTlY+b/LNGcXY+H4b+LHuS2HdUUuuGU9MKN+HWpIziuQSoi4g1hNOgp9ezgqBByQpAHBE/jQraQ3NKZ55xm3TQDm1qFTb8SfOGL4Po2iSm0IL+VA2jWnpjmgjOmshXACusPmtfakE+55uxM3TUa16UQDyfCBfZZEtnaFLTYzJ7KmD2GPot8SCxJBqNmW7AL8pMSIxMC3cRxUbK4R3+KIisXUuB50jZH3zGHxi34e2jA6gDeFmzgHCDJRidHMsCTHTaATzlvVz9YwwNqPQaYY7OFouZXwFxVAxIg/1zVvLc3zx1gWt+UDFeI7h6Eq0h5DZPdUiR4mrhAKd70CgYEAw6WKbPgjzhJI9XVmnu0aMHHH4MK8pbIq4kddChw24yZv1e9qnNTHw3YK17X9Fqog9CU1OX3M/vddfQbc34SorBmtmGYgOfDSuXTct52Ppyl4CRwndYQc0A88Hw+klluTEPY3+NRV6YSzv8vkNMasVuOh0YI1xzbpc+Bb5LL3kwMCgYEA7R4PLYYmtzKAY2YTQOXGBh3xd6UEHgks30W+QzDxvOv75svZt6yDgiwJzXtyrQzbNaH6yca5nfjkqyhnHwpguJ6DK7+S/RnZfVib5MqRwiU7g8l3neKhIXs6xZxfORunDU9T5ntbyNaGv/TJ2cXNw+9VskhBaHfEN/kmaBNNuEMCgYARLuzlfTXH15tI07Lbqn9uWc/wUao381oI3bOyO6Amey2/YHPAqn+RD0EMiRNddjvGta3jCsWCbz9qx7uGdiRKWUcB55ZVAG3BlB3+knwXdnDwe+SLUbsmGvBw2fLesdRM3RM1a5DQHbOb2NCGQhzI1N1VhVYr1QrT/pSTlZRg+QKBgCE05nc/pEhfoC9LakLaauMManaQ+4ShUFFsWPrb7d7BRaPKxJC+biRauny2XxbxB/n410BOvkvrQUre+6ITN/xi5ofH6nPbnOO69woRfFwuDqmkG0ZXKK2hrldiUMuUnc51X5CVkgMMWA6l32bKFsjryZqQF+jjbO1RzRkiKu41AoGAHQer1NyajHEpEfempx8YTsAnOn+Hi33cXAaQoTkS41lX2YK0cBkD18yhubczZcKnMW+GRKZRYXMm0NfwiuIo5oIYWeO6K+rXF+SKptC5mnw/3FhDVnghDAmEqOcRSWnFXARk1WEbFtwG5phDeFrWXsqPzGAjoZ8bhLvKRsrG4OM=",
@ -979,6 +1017,55 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Header: FalseBool(),
Cached: TrueBool(),
},
"remote_account_3_header": {
ID: "01PFPMWK2FF0D9WMHEJHR07C3R",
StatusID: "",
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg",
CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"),
UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 472,
Height: 291,
Size: 137352,
Aspect: 1.6219931271477663,
},
Small: gtsmodel.Small{
Width: 472,
Height: 291,
Size: 137352,
Aspect: 1.6219931271477663,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "062G5WYKY35KKD12EMSM3F8PJ8",
Description: "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
ScheduledStatusID: "",
Blurhash: "LARysgM_IU_3~pD%M_Rj_39FIAt6",
Processing: 2,
File: gtsmodel.File{
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
ContentType: "image/jpeg",
FileSize: 19310,
UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"),
},
Thumbnail: gtsmodel.Thumbnail{
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
ContentType: "image/jpeg",
FileSize: 20395,
UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"),
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg",
},
Avatar: FalseBool(),
Header: TrueBool(),
Cached: TrueBool(),
},
}
}

View file

@ -37,18 +37,6 @@ const (
unknownClass = ^Class(0)
)
var controlToClass = map[rune]Class{
0x202D: LRO, // LeftToRightOverride,
0x202E: RLO, // RightToLeftOverride,
0x202A: LRE, // LeftToRightEmbedding,
0x202B: RLE, // RightToLeftEmbedding,
0x202C: PDF, // PopDirectionalFormat,
0x2066: LRI, // LeftToRightIsolate,
0x2067: RLI, // RightToLeftIsolate,
0x2068: FSI, // FirstStrongIsolate,
0x2069: PDI, // PopDirectionalIsolate,
}
// A trie entry has the following bits:
// 7..5 XOR mask for brackets
// 4 1: Bracket open, 0: Bracket close

4
vendor/modules.txt vendored
View file

@ -660,7 +660,7 @@ golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
## explicit; go 1.18
golang.org/x/exp/constraints
golang.org/x/exp/slices
# golang.org/x/image v0.1.0
# golang.org/x/image v0.2.0
## explicit; go 1.12
golang.org/x/image/bmp
golang.org/x/image/ccitt
@ -701,7 +701,7 @@ golang.org/x/sys/execabs
golang.org/x/sys/internal/unsafeheader
golang.org/x/sys/unix
golang.org/x/sys/windows
# golang.org/x/text v0.4.0
# golang.org/x/text v0.5.0
## explicit; go 1.17
golang.org/x/text/cases
golang.org/x/text/internal

View file

@ -394,3 +394,13 @@ footer {
color: $gray1;
}
}
label {
cursor: pointer;
}
@media (prefers-reduced-motion) {
.fa-spin {
animation: none;
}
}

View file

@ -22,13 +22,14 @@ const React = require("react");
const { useRoute, Link, Redirect } = require("wouter");
const { CategorySelect } = require("./category-select");
const { useComboBoxInput, useFileInput } = require("../../components/form");
const { CategorySelect } = require("../category-select");
const { useComboBoxInput, useFileInput } = require("../../../components/form");
const query = require("../../lib/query");
const FakeToot = require("../../components/fake-toot");
const query = require("../../../lib/query");
const FakeToot = require("../../../components/fake-toot");
const Loading = require("../../../components/loading");
const base = "/settings/admin/custom-emoji";
const base = "/settings/custom-emoji/local";
module.exports = function EmojiDetailRoute() {
let [_match, params] = useRoute(`${base}/:emojiId`);
@ -54,7 +55,11 @@ function EmojiDetailData({emojiId}) {
</div>
);
} else if (isLoading) {
return "Loading...";
return (
<div>
<Loading/>
</div>
);
} else {
return <EmojiDetail emoji={emoji}/>;
}

View file

@ -24,7 +24,7 @@ const {Switch, Route} = require("wouter");
const EmojiOverview = require("./overview");
const EmojiDetail = require("./detail");
const base = "/settings/admin/custom-emoji";
const base = "/settings/custom-emoji/local";
module.exports = function CustomEmoji() {
return (

View file

@ -21,17 +21,19 @@
const Promise = require('bluebird');
const React = require("react");
const FakeToot = require("../../components/fake-toot");
const MutateButton = require("../../components/mutation-button");
const FakeToot = require("../../../components/fake-toot");
const MutateButton = require("../../../components/mutation-button");
const {
useTextInput,
useFileInput,
useComboBoxInput
} = require("../../components/form");
} = require("../../../components/form");
const query = require("../../lib/query");
const { CategorySelect } = require('./category-select');
const query = require("../../../lib/query");
const { CategorySelect } = require('../category-select');
const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function NewEmojiForm({ emoji }) {
const emojiCodes = React.useMemo(() => {
@ -47,9 +49,26 @@ module.exports = function NewEmojiForm({ emoji }) {
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
return emojiCodes.has(code)
? "Shortcode already in use"
: "";
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") {return "";}
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
@ -78,11 +97,13 @@ module.exports = function NewEmojiForm({ emoji }) {
image,
shortcode,
category
});
}).unwrap();
}).then(() => {
resetFile();
resetShortcode();
resetCategory();
}).catch((e) => {
console.error("Emoji upload error:", e);
});
}

View file

@ -23,10 +23,11 @@ const {Link} = require("wouter");
const NewEmojiForm = require("./new-emoji");
const query = require("../../lib/query");
const { useEmojiByCategory } = require("./category-select");
const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select");
const Loading = require("../../../components/loading");
const base = "/settings/admin/custom-emoji";
const base = "/settings/custom-emoji/local";
module.exports = function EmojiOverview() {
const {
@ -37,12 +38,12 @@ module.exports = function EmojiOverview() {
return (
<>
<h1>Custom Emoji</h1>
<h1>Custom Emoji (local)</h1>
{error &&
<div className="error accent">{error}</div>
}
{isLoading
? "Loading..."
? <Loading/>
: <>
<EmojiList emoji={emoji}/>
<NewEmojiForm emoji={emoji}/>

View file

@ -0,0 +1,54 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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/>.
*/
"use strict";
const React = require("react");
const ParseFromToot = require("./parse-from-toot");
const query = require("../../../lib/query");
const Loading = require("../../../components/loading");
module.exports = function RemoteEmoji() {
// local emoji are queried for shortcode collision detection
const {
data: emoji = [],
isLoading,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
return (
<>
<h1>Custom Emoji (remote)</h1>
{error &&
<div className="error accent">{error}</div>
}
{isLoading
? <Loading/>
: <>
<ParseFromToot emoji={emoji} emojiCodes={emojiCodes} />
</>
}
</>
);
};

View file

@ -0,0 +1,319 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const syncpipe = require("syncpipe");
const {
useTextInput,
useComboBoxInput
} = require("../../../components/form");
const { CategorySelect } = require('../category-select');
const query = require("../../../lib/query");
const Loading = require("../../../components/loading");
module.exports = function ParseFromToot({ emojiCodes }) {
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
const [onURLChange, _resetURL, { url }] = useTextInput("url");
const searchResult = React.useMemo(() => {
if (!isSuccess) {
return null;
}
if (data.type == "none") {
return "No results found";
}
if (data.domain == instanceDomain) {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={emojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}, [isSuccess, data, instanceDomain, emojiCodes]);
function submitSearch(e) {
e.preventDefault();
searchStatus(url);
}
return (
<div className="parse-emoji">
<h2>Steal this look</h2>
<form onSubmit={submitSearch}>
<div className="form-field text">
<label htmlFor="url">
Link to a toot:
</label>
<div className="row">
<input
type="text"
id="url"
name="url"
onChange={onURLChange}
value={url}
/>
<button disabled={isLoading}>
<i className={[
"fa",
(isLoading
? "fa-refresh fa-spin"
: "fa-search")
].join(" ")} aria-hidden="true" title="Search"/>
<span className="sr-only">Search</span>
</button>
</div>
{isLoading && <Loading/>}
{error && <div className="error">{error.data.error}</div>}
</div>
</form>
{searchResult}
</div>
);
};
function makeEmojiState(emojiList, checked) {
/* Return a new object, with a key for every emoji's shortcode,
And a value for it's checkbox `checked` state.
*/
return syncpipe(emojiList, [
(_) => _.map((emoji) => [emoji.shortcode, {
checked,
valid: true
}]),
(_) => Object.fromEntries(_)
]);
}
function updateEmojiState(emojiState, checked) {
/* Create a new object with all emoji entries' checked state updated */
return syncpipe(emojiState, [
(_) => Object.entries(emojiState),
(_) => _.map(([key, val]) => [key, {
...val,
checked
}]),
(_) => Object.fromEntries(_)
]);
}
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
const [err, setError] = React.useState();
const toggleAllRef = React.useRef(null);
const [toggleAllState, setToggleAllState] = React.useState(0);
const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false));
const [someSelected, setSomeSelected] = React.useState(false);
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
React.useEffect(() => {
if (emojiList != undefined) {
setEmojiState(makeEmojiState(emojiList, false));
}
}, [emojiList]);
React.useEffect(() => {
/* Updates (un)check all checkbox, based on shortcode checkboxes
Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
*/
if (toggleAllRef.current == null) {
return;
}
let values = Object.values(emojiState);
/* one or more boxes are checked */
let some = values.some((v) => v.checked);
let all = false;
if (some) {
/* there's not at least one unchecked box */
all = !values.some((v) => v.checked == false);
}
setSomeSelected(some);
if (some && !all) {
setToggleAllState(2);
toggleAllRef.current.indeterminate = true;
} else {
setToggleAllState(all ? 1 : 0);
toggleAllRef.current.indeterminate = false;
}
}, [emojiState, toggleAllRef]);
function updateEmoji(shortcode, value) {
setEmojiState({
...emojiState,
[shortcode]: {
...emojiState[shortcode],
...value
}
});
}
function toggleAll(e) {
let selectAll = e.target.checked;
if (toggleAllState == 2) { // indeterminate
selectAll = false;
}
setEmojiState(updateEmojiState(emojiState, selectAll));
setToggleAllState(selectAll);
}
function submit(action) {
Promise.try(() => {
setError(null);
const selectedShortcodes = syncpipe(emojiState, [
(_) => Object.entries(_),
(_) => _.filter(([_shortcode, entry]) => entry.checked),
(_) => _.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
}
return {
shortcode,
localShortcode: entry.shortcode
};
})
]);
return patchRemoteEmojis({
action,
domain,
list: selectedShortcodes,
category
}).unwrap();
}).then(() => {
setEmojiState(makeEmojiState(emojiList, false));
resetCategory();
}).catch((e) => {
if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => (
<div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div>
)));
} else {
setError(e);
}
});
}
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<div className="emoji-list">
<label className="header">
<input
ref={toggleAllRef}
type="checkbox"
onChange={toggleAll}
checked={toggleAllState === 1}
/> All
</label>
{emojiList.map((emoji) => (
<EmojiEntry
key={emoji.shortcode}
emoji={emoji}
localEmojiCodes={localEmojiCodes}
updateEmoji={(value) => updateEmoji(emoji.shortcode, value)}
checked={emojiState[emoji.shortcode].checked}
/>
))}
</div>
<CategorySelect
value={category}
categoryState={categoryState}
/>
<div className="action-buttons row">
<button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button>
<button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button>
</div>
{err && <div className="error">
{err}
</div>}
{patchResult.isSuccess && <div>
Action applied to {patchResult.data.length} emoji
</div>}
</div>
);
}
function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) {
const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", {
defaultValue: emoji.shortcode,
validator: function validateShortcode(code) {
return (checked && localEmojiCodes.has(code))
? "Shortcode already in use"
: "";
}
});
React.useEffect(() => {
updateEmoji({ valid: shortcodeValid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [shortcodeValid]);
return (
<label key={emoji.shortcode} className="row">
<input
type="checkbox"
onChange={(e) => updateEmoji({ checked: e.target.checked })}
checked={checked}
/>
<img className="emoji" src={emoji.url} title={emoji.shortcode} />
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={(e) => {
onShortcodeChange(e);
updateEmoji({ shortcode: e.target.value, checked: true });
}}
value={shortcode}
/>
</label>
);
}

View file

@ -30,6 +30,7 @@ const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const BackButton = require("../components/back-button");
const Loading = require("../components/loading");
const base = "/settings/admin/federation";
@ -56,7 +57,9 @@ module.exports = function AdminSettings() {
return (
<div>
<h1>Federation</h1>
Loading...
<div>
<Loading/>
</div>
</div>
);
}
@ -321,7 +324,7 @@ function InstancePage({domain, Form}) {
const [statusMsg, setStatus] = React.useState("");
if (entry == undefined) {
return "Loading...";
return <Loading/>;
}
const updateBlock = submit(

View file

@ -20,17 +20,14 @@
const React = require("react");
module.exports = function useTextInput({name, Name}, {validator} = {}) {
const [text, setText] = React.useState("");
module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) {
const [text, setText] = React.useState(defaultValue);
const [valid, setValid] = React.useState(true);
const textRef = React.useRef(null);
function onChange(e) {
let input = e.target.value;
setText(input);
if (validator) {
validator(input);
}
}
function reset() {
@ -39,7 +36,9 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) {
React.useEffect(() => {
if (validator) {
textRef.current.setCustomValidity(validator(text));
let res = validator(text);
setValid(res == "");
textRef.current.setCustomValidity(res);
textRef.current.reportValidity();
}
}, [text, textRef, validator]);
@ -50,7 +49,8 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) {
{
[name]: text,
[`${name}Ref`]: textRef,
[`set${Name}`]: setText
[`set${Name}`]: setText,
[`${name}Valid`]: valid
}
];
};

View file

@ -0,0 +1,27 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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/>.
*/
"use strict";
const React = require("react");
module.exports = function Loading() {
return (
<i className="fa fa-spin fa-refresh" aria-label="Loading" title="Loading"/>
);
};

View file

@ -32,6 +32,7 @@ const oauth = require("./redux/reducers/oauth").actions;
const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login");
const Loading = require("./components/loading");
require("./style.css");
@ -46,7 +47,11 @@ const nav = {
"Instance Settings": require("./admin/settings.js"),
"Actions": require("./admin/actions"),
"Federation": require("./admin/federation.js"),
"Custom Emoji": require("./admin/emoji"),
},
"Custom Emoji": {
adminOnly: true,
"Local": require("./admin/emoji/local"),
"Remote": require("./admin/emoji/remote"),
}
};
@ -167,7 +172,7 @@ function App() {
function Main() {
return (
<Provider store={store}>
<PersistGate loading={"loading..."} persistor={persistor}>
<PersistGate loading={<section><Loading/></section>} persistor={persistor}>
<App />
</PersistGate>
</Provider>

View file

@ -18,8 +18,18 @@
"use strict";
const Promise = require("bluebird");
const base = require("./base");
function unwrap(res) {
if (res.error != undefined) {
throw res.error;
} else {
return res.data;
}
}
const endpoints = (build) => ({
getAllEmoji: build.query({
query: (params = {}) => ({
@ -77,6 +87,93 @@ const endpoints = (build) => ({
url: `/api/v1/admin/custom_emojis/${id}`
}),
invalidatesTags: (res, error, id) => [{type: "Emojis", id}]
}),
searchStatusForEmoji: build.mutation({
query: (url) => ({
method: "GET",
url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1`
}),
transformResponse: (res) => {
/* Parses search response, prioritizing a toot result,
and returns referenced custom emoji
*/
let type;
if (res.statuses.length > 0) {
type = "statuses";
} else if (res.accounts.length > 0) {
type = "accounts";
} else {
return {
type: "none"
};
}
let data = res[type][0];
return {
type,
domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225
list: data.emojis
};
}
}),
patchRemoteEmojis: build.mutation({
queryFn: ({action, domain, list, category}, api, _extraOpts, baseQuery) => {
const data = [];
const errors = [];
return Promise.each(list, (emoji) => {
return Promise.try(() => {
return baseQuery({
method: "GET",
url: `/api/v1/admin/custom_emojis`,
params: {
filter: `domain:${domain},shortcode:${emoji.shortcode}`,
limit: 1
}
}).then(unwrap);
}).then(([lookup]) => {
if (lookup == undefined) { throw "not found"; }
let body = {
type: action
};
if (action == "copy") {
body.shortcode = emoji.localShortcode ?? emoji.shortcode;
if (category.trim().length != 0) {
body.category = category;
}
}
return baseQuery({
method: "PATCH",
url: `/api/v1/admin/custom_emojis/${lookup.id}`,
asForm: true,
body: body
}).then(unwrap);
}).then((res) => {
data.push([emoji.shortcode, res]);
}).catch((e) => {
console.error("emoji lookup for", emoji.shortcode, "failed:", e);
let msg = e.message ?? e;
if (e.data.error) {
msg = e.data.error;
}
errors.push([emoji.shortcode, msg]);
});
}).then(() => {
if (errors.length == 0) {
return { data };
} else {
return {
error: errors
};
}
});
},
invalidatesTags: () => [{type: "Emojis", id: "LIST"}]
})
});

View file

@ -598,4 +598,52 @@ span.form-info {
.left-border {
border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem;
}
.parse-emoji {
.parsed {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
& > span {
margin-bottom: -1rem;
}
.action-buttons {
gap: 1rem;
}
.emoji-list {
display: flex;
flex-direction: column;
& > * {
gap: 1rem;
align-items: center;
padding: 0.5rem 1rem;
}
.header {
background: $gray2;
display: flex;
}
.row {
display: grid;
grid-template-columns: auto auto 1fr;
&:hover {
background: $settings-entry-hover-bg;
}
}
.emoji {
height: 2rem;
width: 2rem;
margin: 0;
}
}
}
}