[chore] media pipeline improvements (#3110)

* don't set emoji / media image paths on failed download, migrate FileType from string to integer

* fix incorrect uses of util.PtrOr, fix returned frontend media

* fix migration not setting arguments correctly in where clause

* fix not providing default with not null column

* whoops

* ensure a default gets set for media attachment file type

* remove the exclusive flag from writing files in disk storage

* rename PtrOr -> PtrOrZero, and rename PtrValueOr -> PtrOrValue to match

* slight wording changes

* use singular / plural word forms (no parentheses), is better for screen readers

* update testmodels with unknown media type to have unset file details, update attachment focus handling converting to frontend, update tests

* store first instance in ffmpeg wasm pool, fill remaining with closed instances
This commit is contained in:
kim 2024-07-17 15:26:33 +00:00 committed by GitHub
parent 0aadc2db2a
commit 72ba5666a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 669 additions and 399 deletions

View file

@ -24,6 +24,7 @@ import (
"os/signal"
"syscall"
"codeberg.org/gruf/go-logger/v2/level"
"codeberg.org/gruf/go-storage/memory"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
@ -40,6 +41,8 @@ func main() {
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer cncl()
log.SetLevel(level.INFO)
if len(os.Args) != 3 {
log.Panic(ctx, "Usage: go run ./cmd/process-emoji <input-file> <output-static>")
}

View file

@ -24,6 +24,7 @@ import (
"os/signal"
"syscall"
"codeberg.org/gruf/go-logger/v2/level"
"codeberg.org/gruf/go-storage/memory"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
@ -39,6 +40,8 @@ func main() {
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer cncl()
log.SetLevel(level.INFO)
if len(os.Args) != 4 {
log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")
}

View file

@ -138,7 +138,7 @@ func (m *Module) AccountMutePOSTHandler(c *gin.Context) {
func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {
// Apply defaults for missing fields.
form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false))
form.Notifications = util.Ptr(util.PtrOrValue(form.Notifications, false))
// Normalize mute duration if necessary.
// If we parsed this as JSON, expires_in

View file

@ -40,8 +40,8 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1
}
// Apply defaults for missing fields.
form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
form.Irreversible = util.Ptr(util.PtrValueOr(form.Irreversible, false))
form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))
form.Irreversible = util.Ptr(util.PtrOrValue(form.Irreversible, false))
if *form.Irreversible {
return errors.New("irreversible aka server-side drop filters are not supported yet")

View file

@ -100,7 +100,7 @@ func (suite *FiltersTestSuite) TestGetFilterKeyword() {
suite.NotEmpty(filterKeyword)
suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID)
suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword)
suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord)
suite.Equal(util.PtrOrValue(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() {

View file

@ -147,7 +147,7 @@ func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCrea
return err
}
form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))
return nil
}

View file

@ -192,7 +192,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
if err := validate.FilterTitle(form.Title); err != nil {
return err
}
action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn)
action := util.PtrOrValue(form.FilterAction, apimodel.FilterActionWarn)
if err := validate.FilterAction(action); err != nil {
return err
}
@ -253,7 +253,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {
return err
}
form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false))
form.Keywords[i].WholeWord = util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false))
}
for _, formStatus := range form.Statuses {
if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil {

View file

@ -289,7 +289,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
destroy := util.PtrValueOr(formKeyword.Destroy, false)
destroy := util.PtrOrValue(formKeyword.Destroy, false)
form.Keywords[i].Destroy = &destroy
if destroy && formKeyword.ID == nil {
@ -305,7 +305,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
destroy := util.PtrValueOr(formStatus.Destroy, false)
destroy := util.PtrOrValue(formStatus.Destroy, false)
form.Statuses[i].Destroy = &destroy
switch {

View file

@ -78,12 +78,12 @@ func init() {
CreatedAt: account.CreatedAt,
Reason: account.Reason,
Privacy: newgtsmodel.Visibility(account.Privacy),
Sensitive: util.Ptr(util.PtrValueOr(account.Sensitive, false)),
Sensitive: util.Ptr(util.PtrOrValue(account.Sensitive, false)),
Language: account.Language,
StatusContentType: account.StatusContentType,
CustomCSS: account.CustomCSS,
EnableRSS: util.Ptr(util.PtrValueOr(account.EnableRSS, false)),
HideCollections: util.Ptr(util.PtrValueOr(account.HideCollections, false)),
EnableRSS: util.Ptr(util.PtrOrValue(account.EnableRSS, false)),
HideCollections: util.Ptr(util.PtrOrValue(account.HideCollections, false)),
}
// Insert the settings model.

View file

@ -0,0 +1,124 @@
// 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 migrations
import (
"context"
old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements"
new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.NewAddColumn().
Table("media_attachments").
ColumnExpr("? INTEGER NOT NULL DEFAULT ?", bun.Ident("type_new"), 0).
Exec(ctx); err != nil {
return err
}
for old, new := range map[old_gtsmodel.FileType]new_gtsmodel.FileType{
old_gtsmodel.FileTypeAudio: new_gtsmodel.FileTypeAudio,
old_gtsmodel.FileTypeImage: new_gtsmodel.FileTypeImage,
old_gtsmodel.FileTypeGifv: new_gtsmodel.FileTypeImage,
old_gtsmodel.FileTypeVideo: new_gtsmodel.FileTypeVideo,
old_gtsmodel.FileTypeUnknown: new_gtsmodel.FileTypeUnknown,
} {
if _, err := tx.NewUpdate().
Table("media_attachments").
Where("? = ?", bun.Ident("type"), old).
Set("? = ?", bun.Ident("type_new"), new).
Exec(ctx); err != nil {
return err
}
}
if _, err := tx.NewDropColumn().
Table("media_attachments").
ColumnExpr("?", bun.Ident("type")).
Exec(ctx); err != nil {
return err
}
if _, err := tx.NewRaw(
"ALTER TABLE ? RENAME COLUMN ? TO ?",
bun.Ident("media_attachments"),
bun.Ident("type_new"),
bun.Ident("type"),
).Exec(ctx); err != nil {
return err
}
return nil
}); err != nil {
return err
}
// Zero-out attachment data
// for "unknown" non-locally
// stored media attachments.
if _, err := db.NewUpdate().
Table("media_attachments").
Where("? = ?", bun.Ident("type"), new_gtsmodel.FileTypeUnknown).
Set("? = ?", bun.Ident("url"), "").
Set("? = ?", bun.Ident("file_path"), "").
Set("? = ?", bun.Ident("file_content_type"), "").
Set("? = ?", bun.Ident("file_file_size"), 0).
Set("? = ?", bun.Ident("thumbnail_path"), "").
Set("? = ?", bun.Ident("thumbnail_content_type"), "").
Set("? = ?", bun.Ident("thumbnail_file_size"), 0).
Set("? = ?", bun.Ident("thumbnail_url"), "").
Exec(ctx); err != nil {
return err
}
// Zero-out emoji data for
// non-locally stored emoji.
if _, err := db.NewUpdate().
Table("emojis").
WhereOr("? = ?", bun.Ident("image_url"), "").
WhereOr("? = ?", bun.Ident("image_path"), "").
Set("? = ?", bun.Ident("image_path"), "").
Set("? = ?", bun.Ident("image_url"), "").
Set("? = ?", bun.Ident("image_file_size"), 0).
Set("? = ?", bun.Ident("image_content_type"), "").
Set("? = ?", bun.Ident("image_static_path"), "").
Set("? = ?", bun.Ident("image_static_url"), "").
Set("? = ?", bun.Ident("image_static_file_size"), 0).
Set("? = ?", bun.Ident("image_static_content_type"), "").
Exec(ctx); err != nil {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,65 @@
// 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 gtsmodel
import "time"
// Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance.
type Emoji struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain.
Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis.
ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis.
ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system.
ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system
ImageContentType string `bun:",notnull"` // MIME content type of the emoji image
ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image.
ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes.
ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes.
Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown?
URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234'
VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker?
Category *EmojiCategory `bun:"rel:belongs-to"` // In which emoji category is this emoji visible?
CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to.
Cached *bool `bun:",nullzero,notnull,default:false"` // whether emoji is cached in locally in gotosocial storage.
}
// IsLocal returns true if the emoji is
// local to this instance., ie., it did
// not originate from a remote instance.
func (e *Emoji) IsLocal() bool {
return e.Domain == ""
}
// ShortcodeDomain returns the [shortcode]@[domain] for the given emoji.
func (e *Emoji) ShortcodeDomain() string {
return e.Shortcode + "@" + e.Domain
}
// EmojiCategory represents a grouping of custom emojis.
type EmojiCategory struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Name string `bun:",nullzero,notnull,unique"` // name of this category
}

View file

@ -0,0 +1,127 @@
// 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 gtsmodel
import (
"time"
)
// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
// somewhere in storage and that can be retrieved and served by the router.
type MediaAttachment struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached
URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server
RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media)
Type FileType `bun:",notnull"` // Type of file (image/gifv/audio/video/unknown)
FileMeta FileMeta `bun:",embed:,notnull"` // Metadata about the file
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
Description string `bun:""` // Description of the attachment (for screenreaders)
ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment
Processing ProcessingStatus `bun:",notnull,default:2"` // What is the processing status of this attachment
File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file
Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file.
Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar?
Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header?
Cached *bool `bun:",nullzero,notnull,default:false"` // Is this attachment currently cached by our instance?
}
// IsLocal returns whether media attachment is local.
func (m *MediaAttachment) IsLocal() bool {
return m.RemoteURL == ""
}
// IsRemote returns whether media attachment is remote.
func (m *MediaAttachment) IsRemote() bool {
return m.RemoteURL != ""
}
// File refers to the metadata for the whole file
type File struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
}
// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
type Thumbnail struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
URL string `bun:",nullzero"` // What is the URL of the thumbnail on the local server
RemoteURL string `bun:",nullzero"` // What is the remote URL of the thumbnail (empty for local media)
}
// ProcessingStatus refers to how far along in the processing stage the attachment is.
type ProcessingStatus int
// MediaAttachment processing states.
const (
ProcessingStatusReceived ProcessingStatus = 0 // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet.
ProcessingStatusProcessing ProcessingStatus = 1 // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not.
ProcessingStatusProcessed ProcessingStatus = 2 // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served.
ProcessingStatusError ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.
)
// FileType refers to the file type of the media attaachment.
type FileType string
// MediaAttachment file types.
const (
FileTypeImage FileType = "Image" // FileTypeImage is for jpegs, pngs, and standard gifs
FileTypeGifv FileType = "Gifv" // FileTypeGif is for soundless looping videos that behave like gifs
FileTypeAudio FileType = "Audio" // FileTypeAudio is for audio-only files (no video)
FileTypeVideo FileType = "Video" // FileTypeVideo is for files with audio + visual
FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!)
)
// FileMeta describes metadata about the actual contents of the file.
type FileMeta struct {
Original Original `bun:"embed:original_"`
Small Small `bun:"embed:small_"`
Focus Focus `bun:"embed:focus_"`
}
// Small can be used for a thumbnail of any media type
type Small struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
}
// Original can be used for original metadata for any media type
type Original struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
Duration *float32 // video-specific: duration of the video in seconds
Framerate *float32 // video-specific: fps
Bitrate *uint64 // video-specific: bitrate
}
// Focus describes the 'center' of the image for display purposes.
// X and Y should each be between -1 and 1
type Focus struct {
X float32
Y float32
}

View file

@ -30,7 +30,7 @@ type MediaAttachment struct {
StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached
URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server
RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media)
Type FileType `bun:",notnull"` // Type of file (image/gifv/audio/video/unknown)
Type FileType `bun:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown)
FileMeta FileMeta `bun:",embed:,notnull"` // Metadata about the file
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
Description string `bun:""` // Description of the attachment (for screenreaders)
@ -81,18 +81,34 @@ const (
ProcessingStatusError ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.
)
// FileType refers to the file type of the media attaachment.
type FileType string
// FileType refers to the file
// type of the media attaachment.
type FileType int
// MediaAttachment file types.
const (
FileTypeImage FileType = "Image" // FileTypeImage is for jpegs, pngs, and standard gifs
FileTypeGifv FileType = "Gifv" // FileTypeGif is for soundless looping videos that behave like gifs
FileTypeAudio FileType = "Audio" // FileTypeAudio is for audio-only files (no video)
FileTypeVideo FileType = "Video" // FileTypeVideo is for files with audio + visual
FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!)
// MediaAttachment file types.
FileTypeUnknown FileType = 0 // FileTypeUnknown is for unknown file types (surprise surprise!)
FileTypeImage FileType = 1 // FileTypeImage is for jpegs, pngs, and standard gifs
FileTypeAudio FileType = 2 // FileTypeAudio is for audio-only files (no video)
FileTypeVideo FileType = 3 // FileTypeVideo is for files with audio + visual
)
// String returns a stringified, frontend API compatible form of FileType.
func (t FileType) String() string {
switch t {
case FileTypeUnknown:
return "unknown"
case FileTypeImage:
return "image"
case FileTypeAudio:
return "audio"
case FileTypeVideo:
return "video"
default:
panic("invalid filetype")
}
}
// FileMeta describes metadata about the actual contents of the file.
type FileMeta struct {
Original Original `bun:"embed:original_"`

View file

@ -34,14 +34,33 @@ type wasmInstancePool struct {
}
func (p *wasmInstancePool) Init(ctx context.Context, sz int) error {
p.pool = make(chan *wasm.Instance, sz)
for i := 0; i < sz; i++ {
inst, err := p.inst.New(ctx)
if err != nil {
return err
}
p.pool <- inst
// Initialize for first time
// to preload module into the
// wazero compilation cache.
inst, err := p.inst.New(ctx)
if err != nil {
return err
}
// Clamp to 1.
if sz <= 0 {
sz = 1
}
// Allocate new pool instance channel.
p.pool = make(chan *wasm.Instance, sz)
// Store only one
// open instance
// at init time.
p.pool <- inst
// Fill reminaing with closed
// instances for later opening.
for i := 0; i < sz-1; i++ {
p.pool <- new(wasm.Instance)
}
return nil
}

View file

@ -102,74 +102,19 @@ func (m *Manager) CreateMedia(
) {
now := time.Now()
// Generate new ID.
id := id.NewULID()
// Placeholder URL for attachment.
url := uris.URIForAttachment(
accountID,
string(TypeAttachment),
string(SizeOriginal),
id,
"unknown",
)
// Placeholder storage path for attachment.
path := uris.StoragePathForAttachment(
accountID,
string(TypeAttachment),
string(SizeOriginal),
id,
"unknown",
)
// Calculate attachment thumbnail file path
thumbPath := uris.StoragePathForAttachment(
accountID,
string(TypeAttachment),
string(SizeSmall),
id,
// Always encode attachment
// thumbnails as jpeg.
"jpeg",
)
// Calculate attachment thumbnail URL.
thumbURL := uris.URIForAttachment(
accountID,
string(TypeAttachment),
string(SizeSmall),
id,
// Always encode attachment
// thumbnails as jpeg.
"jpeg",
)
// Populate initial fields on the new media,
// leaving out fields with values we don't know
// yet. These will be overwritten as we go.
attachment := &gtsmodel.MediaAttachment{
ID: id,
ID: id.NewULID(),
AccountID: accountID,
Type: gtsmodel.FileTypeUnknown,
Processing: gtsmodel.ProcessingStatusReceived,
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
CreatedAt: now,
UpdatedAt: now,
URL: url,
Type: gtsmodel.FileTypeUnknown,
AccountID: accountID,
Processing: gtsmodel.ProcessingStatusReceived,
File: gtsmodel.File{
ContentType: "application/octet-stream",
Path: path,
},
Thumbnail: gtsmodel.Thumbnail{
ContentType: "image/jpeg",
Path: thumbPath,
URL: thumbURL,
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
}
// Check if we were provided additional info
@ -252,56 +197,23 @@ func (m *Manager) CreateEmoji(
// Generate new ID.
id := id.NewULID()
// Fetch the local instance account for emoji path generation.
instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, gtserror.Newf("error fetching instance account: %w", err)
}
if domain == "" && info.URI == nil {
// Generate URI for local emoji.
uri := uris.URIForEmoji(id)
info.URI = &uri
}
// Generate static URL for attachment.
staticURL := uris.URIForAttachment(
instanceAcc.ID,
string(TypeEmoji),
string(SizeStatic),
id,
// All static emojis
// are encoded as png.
"png",
)
// Generate static image path for attachment.
staticPath := uris.StoragePathForAttachment(
instanceAcc.ID,
string(TypeEmoji),
string(SizeStatic),
id,
// All static emojis
// are encoded as png.
"png",
)
// Populate initial fields on the new emoji,
// leaving out fields with values we don't know
// yet. These will be overwritten as we go.
emoji := &gtsmodel.Emoji{
ID: id,
Shortcode: shortcode,
Domain: domain,
ImageStaticURL: staticURL,
ImageStaticPath: staticPath,
ImageStaticContentType: "image/png",
Disabled: util.Ptr(false),
VisibleInPicker: util.Ptr(true),
CreatedAt: now,
UpdatedAt: now,
ID: id,
Shortcode: shortcode,
Domain: domain,
Disabled: util.Ptr(false),
VisibleInPicker: util.Ptr(true),
CreatedAt: now,
UpdatedAt: now,
}
// Finally, create new emoji.
@ -327,12 +239,6 @@ func (m *Manager) RefreshEmoji(
*ProcessingEmoji,
error,
) {
// Fetch the local instance account for emoji path generation.
instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, gtserror.Newf("error fetching instance account: %w", err)
}
// Create references to old emoji image
// paths before they get updated with new
// path ID. These are required for later
@ -380,38 +286,6 @@ func (m *Manager) RefreshEmoji(
return rct, nil
}
// Use a new ID to create a new path
// for the new images, to get around
// needing to do cache invalidation.
newPathID, err := id.NewRandomULID()
if err != nil {
return nil, gtserror.Newf("error generating newPathID for emoji refresh: %s", err)
}
// Generate new static URL for emoji.
emoji.ImageStaticURL = uris.URIForAttachment(
instanceAcc.ID,
string(TypeEmoji),
string(SizeStatic),
newPathID,
// All static emojis
// are encoded as png.
"png",
)
// Generate new static image storage path for emoji.
emoji.ImageStaticPath = uris.StoragePathForAttachment(
instanceAcc.ID,
string(TypeEmoji),
string(SizeStatic),
newPathID,
// All static emojis
// are encoded as png.
"png",
)
// Finally, create new emoji in database.
processingEmoji, err := m.createEmoji(ctx,
func(ctx context.Context, emoji *gtsmodel.Emoji) error {
@ -425,8 +299,8 @@ func (m *Manager) RefreshEmoji(
return nil, err
}
// Set the refreshed path ID used.
processingEmoji.newPathID = newPathID
// Generate a new path ID to use instead.
processingEmoji.newPathID = id.NewULID()
return processingEmoji, nil
}
@ -441,6 +315,12 @@ func (m *Manager) createEmoji(
*ProcessingEmoji,
error,
) {
// Fetch the local instance account for emoji path generation.
instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, gtserror.Newf("error fetching instance account: %w", err)
}
// Check if we have additional info to add to the emoji,
// and overwrite some of the emoji fields if so.
if info.URI != nil {
@ -475,9 +355,10 @@ func (m *Manager) createEmoji(
// Return wrapped emoji for later processing.
processingEmoji := &ProcessingEmoji{
emoji: emoji,
dataFn: data,
mgr: m,
instAccID: instanceAcc.ID,
emoji: emoji,
dataFn: data,
mgr: m,
}
return processingEmoji, nil

View file

@ -358,11 +358,10 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
suite.Equal(processing.ID(), attachment.ID)
suite.Equal(accountID, attachment.AccountID)
// file meta should be correctly derived from the image
suite.Zero(attachment.FileMeta)
suite.Equal("application/octet-stream", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Empty(attachment.Blurhash)
suite.Zero(attachment.File.ContentType)
suite.Zero(attachment.Thumbnail.ContentType)
suite.Zero(attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
@ -376,7 +375,6 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
stored, err := suite.storage.Has(ctx, attachment.File.Path)
suite.NoError(err)
suite.False(stored)
stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
suite.NoError(err)
suite.False(stored)

View file

@ -26,7 +26,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/regexes"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
@ -36,6 +35,7 @@ import (
// various functions for retrieving data from the process.
type ProcessingEmoji struct {
emoji *gtsmodel.Emoji // processing emoji details
instAccID string // instance account ID
newPathID string // new emoji path ID to use when being refreshed
dataFn DataFunc // load-data function, returns media stream
done bool // done is set when process finishes with non ctx canceled type error
@ -191,21 +191,24 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
pathID = p.emoji.ID
}
// Determine instance account ID from generated image static path.
instanceAccID, ok := getInstanceAccountID(p.emoji.ImageStaticPath)
if !ok {
return gtserror.Newf("invalid emoji static path; no instance account id: %s", p.emoji.ImageStaticPath)
}
// Calculate final media attachment file path.
// Calculate final emoji media file path.
p.emoji.ImagePath = uris.StoragePathForAttachment(
instanceAccID,
p.instAccID,
string(TypeEmoji),
string(SizeOriginal),
pathID,
ext,
)
// Calculate final emoji static media file path.
p.emoji.ImageStaticPath = uris.StoragePathForAttachment(
p.instAccID,
string(TypeEmoji),
string(SizeStatic),
pathID,
"png",
)
// Copy temporary file into storage at path.
filesz, err := p.mgr.state.Storage.PutFile(ctx,
p.emoji.ImagePath,
@ -228,19 +231,31 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
p.emoji.ImageFileSize = int(filesz)
p.emoji.ImageStaticFileSize = int(staticsz)
// Fill in remaining emoji data now it's stored.
// Generate an emoji media static URL.
p.emoji.ImageURL = uris.URIForAttachment(
instanceAccID,
p.instAccID,
string(TypeEmoji),
string(SizeOriginal),
pathID,
ext,
)
// Generate an emoji image static URL.
p.emoji.ImageStaticURL = uris.URIForAttachment(
p.instAccID,
string(TypeEmoji),
string(SizeStatic),
pathID,
"png",
)
// Get mimetype for the file container
// type, falling back to generic data.
p.emoji.ImageContentType = getMimeType(ext)
// Set the known emoji static content type.
p.emoji.ImageStaticContentType = "image/png"
// We can now consider this cached.
p.emoji.Cached = util.Ptr(true)
@ -268,16 +283,16 @@ func (p *ProcessingEmoji) cleanup(ctx context.Context) {
}
}
// Unset processor-calculated fields.
p.emoji.ImageStaticContentType = ""
p.emoji.ImageStaticFileSize = 0
p.emoji.ImageStaticPath = ""
p.emoji.ImageStaticURL = ""
p.emoji.ImageContentType = ""
p.emoji.ImageFileSize = 0
p.emoji.ImagePath = ""
p.emoji.ImageURL = ""
// Ensure marked as not cached.
p.emoji.Cached = util.Ptr(false)
}
// getInstanceAccountID determines the instance account ID from
// emoji static image storage path. returns false on failure.
func getInstanceAccountID(staticPath string) (string, bool) {
matches := regexes.FilePath.FindStringSubmatch(staticPath)
if len(matches) < 2 {
return "", false
}
return matches[1], true
}

View file

@ -248,6 +248,15 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
return gtserror.Newf("error generating thumb blurhash: %w", err)
}
}
// Calculate final media attachment thumbnail path.
p.media.Thumbnail.Path = uris.StoragePathForAttachment(
p.media.AccountID,
string(TypeAttachment),
string(SizeSmall),
p.media.ID,
"jpeg",
)
}
// Calculate final media attachment file path.
@ -285,8 +294,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
p.media.Thumbnail.FileSize = int(thumbsz)
}
// Fill in correct attachment
// data now we've parsed it.
// Generate a media attachment URL.
p.media.URL = uris.URIForAttachment(
p.media.AccountID,
string(TypeAttachment),
@ -295,10 +303,22 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
ext,
)
// Generate a media attachment thumbnail URL.
p.media.Thumbnail.URL = uris.URIForAttachment(
p.media.AccountID,
string(TypeAttachment),
string(SizeSmall),
p.media.ID,
"jpeg",
)
// Get mimetype for the file container
// type, falling back to generic data.
p.media.File.ContentType = getMimeType(ext)
// Set the known thumbnail content type.
p.media.Thumbnail.ContentType = "image/jpeg"
// We can now consider this cached.
p.media.Cached = util.Ptr(true)
@ -329,6 +349,18 @@ func (p *ProcessingMedia) cleanup(ctx context.Context) {
}
}
// Unset all processor-calculated media fields.
p.media.FileMeta.Original = gtsmodel.Original{}
p.media.FileMeta.Small = gtsmodel.Small{}
p.media.File.ContentType = ""
p.media.File.FileSize = 0
p.media.File.Path = ""
p.media.Thumbnail.FileSize = 0
p.media.Thumbnail.ContentType = ""
p.media.Thumbnail.Path = ""
p.media.Thumbnail.URL = ""
p.media.URL = ""
// Also ensure marked as unknown and finished
// processing so gets inserted as placeholder URL.
p.media.Processing = gtsmodel.ProcessingStatusProcessed

View file

@ -117,8 +117,8 @@ func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmode
if targetAccount.IsLocal() && !*targetAccount.Locked {
rel.Requested = false
rel.Following = true
rel.ShowingReblogs = util.PtrValueOr(fr.ShowReblogs, true)
rel.Notifying = util.PtrValueOr(fr.Notify, false)
rel.ShowingReblogs = util.PtrOrValue(fr.ShowReblogs, true)
rel.Notifying = util.PtrOrValue(fr.Notify, false)
}
// Handle side effects async.

View file

@ -325,8 +325,8 @@ func (p *Processor) emojiUpdateCopy(
// Attempt to create the new local emoji.
emoji, errWithCode := p.createEmoji(ctx,
util.PtrValueOr(shortcode, ""),
util.PtrValueOr(categoryName, ""),
util.PtrOrValue(shortcode, ""),
util.PtrOrValue(categoryName, ""),
data,
)
if errWithCode != nil {

View file

@ -71,7 +71,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
FilterID: filter.ID,
Filter: filter,
Keyword: form.Phrase,
WholeWord: util.Ptr(util.PtrValueOr(form.WholeWord, false)),
WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)),
}
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}

View file

@ -108,11 +108,11 @@ func (p *Processor) Update(
if expiresAt != filter.ExpiresAt {
forbiddenFields = append(forbiddenFields, "expires_in")
}
if contextHome != util.PtrValueOr(filter.ContextHome, false) ||
contextNotifications != util.PtrValueOr(filter.ContextNotifications, false) ||
contextPublic != util.PtrValueOr(filter.ContextPublic, false) ||
contextThread != util.PtrValueOr(filter.ContextThread, false) ||
contextAccount != util.PtrValueOr(filter.ContextAccount, false) {
if contextHome != util.PtrOrValue(filter.ContextHome, false) ||
contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) ||
contextPublic != util.PtrOrValue(filter.ContextPublic, false) ||
contextThread != util.PtrOrValue(filter.ContextThread, false) ||
contextAccount != util.PtrOrValue(filter.ContextAccount, false) {
forbiddenFields = append(forbiddenFields, "context")
}
if len(forbiddenFields) > 0 {
@ -132,7 +132,7 @@ func (p *Processor) Update(
filter.ContextThread = &contextThread
filter.ContextAccount = &contextAccount
filterKeyword.Keyword = form.Phrase
filterKeyword.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
filterKeyword.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))
// We only want to update the relevant filter keyword.
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}

View file

@ -189,7 +189,7 @@ func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.Filter
FilterID: filter.ID,
Filter: filter,
Keyword: *formKeyword.Keyword,
WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)),
WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)),
}
filterKeywordsByID[filterKeyword.ID] = filterKeyword
// Don't need to set columns, as we're using all of them.

View file

@ -26,7 +26,6 @@ import (
"net/url"
"os"
"path"
"syscall"
"time"
"codeberg.org/gruf/go-bytesize"
@ -245,13 +244,9 @@ func NewFileStorage() (*Driver, error) {
// Load runtime configuration
basePath := config.GetStorageLocalBasePath()
// Use default disk config but with
// increased write buffer size and
// 'exclusive' bit sets when creating
// files to ensure we don't overwrite
// existing files unless intending to.
// Use default disk config with
// increased write buffer size.
diskCfg := disk.DefaultConfig()
diskCfg.OpenWrite.Flags |= syscall.O_EXCL
diskCfg.WriteBufSize = int(16 * bytesize.KiB)
// Open the disk storage implementation

View file

@ -21,8 +21,6 @@ import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@ -321,9 +319,9 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
}
var (
locked = util.PtrValueOr(a.Locked, true)
discoverable = util.PtrValueOr(a.Discoverable, false)
bot = util.PtrValueOr(a.Bot, false)
locked = util.PtrOrValue(a.Locked, true)
discoverable = util.PtrOrValue(a.Discoverable, false)
bot = util.PtrOrValue(a.Bot, false)
)
// Remaining properties are simple and
@ -565,84 +563,59 @@ func (c *Converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati
}
// AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API.
func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
apiAttachment := apimodel.Attachment{
ID: a.ID,
Type: strings.ToLower(string(a.Type)),
}
func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, media *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
var api apimodel.Attachment
api.Type = media.Type.String()
api.ID = media.ID
// Don't try to serialize meta for
// unknown attachments, there's no point.
if a.Type != gtsmodel.FileTypeUnknown {
apiAttachment.Meta = &apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: a.FileMeta.Original.Width,
Height: a.FileMeta.Original.Height,
},
Small: apimodel.MediaDimensions{
Width: a.FileMeta.Small.Width,
Height: a.FileMeta.Small.Height,
Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height),
Aspect: float32(a.FileMeta.Small.Aspect),
},
// Only add file details if
// we have stored locally.
if media.File.Path != "" {
api.Meta = new(apimodel.MediaMeta)
api.Meta.Original = apimodel.MediaDimensions{
Width: media.FileMeta.Original.Width,
Height: media.FileMeta.Original.Height,
Aspect: media.FileMeta.Original.Aspect,
Size: toAPISize(media.FileMeta.Original.Width, media.FileMeta.Original.Height),
FrameRate: toAPIFrameRate(media.FileMeta.Original.Framerate),
Duration: util.PtrOrZero(media.FileMeta.Original.Duration),
Bitrate: int(util.PtrOrZero(media.FileMeta.Original.Bitrate)),
}
// Copy over local file URL.
api.URL = util.Ptr(media.URL)
api.TextURL = util.Ptr(media.URL)
// Set file focus details.
// (this doesn't make much sense if media
// has no image, but the API doesn't yet
// distinguish between zero values vs. none).
api.Meta.Focus = new(apimodel.MediaFocus)
api.Meta.Focus.X = media.FileMeta.Focus.X
api.Meta.Focus.Y = media.FileMeta.Focus.Y
// Only add thumbnail details if
// we have thumbnail stored locally.
if media.Thumbnail.Path != "" {
api.Meta.Small = apimodel.MediaDimensions{
Width: media.FileMeta.Small.Width,
Height: media.FileMeta.Small.Height,
Aspect: media.FileMeta.Small.Aspect,
Size: toAPISize(media.FileMeta.Small.Width, media.FileMeta.Small.Height),
}
// Copy over local thumbnail file URL.
api.PreviewURL = util.Ptr(media.Thumbnail.URL)
}
}
if i := a.Blurhash; i != "" {
apiAttachment.Blurhash = &i
}
// Set remaining API attachment fields.
api.Blurhash = util.PtrIf(media.Blurhash)
api.RemoteURL = util.PtrIf(media.RemoteURL)
api.PreviewRemoteURL = util.PtrIf(media.Thumbnail.RemoteURL)
api.Description = util.PtrIf(media.Description)
if i := a.URL; i != "" {
apiAttachment.URL = &i
apiAttachment.TextURL = &i
}
if i := a.Thumbnail.URL; i != "" {
apiAttachment.PreviewURL = &i
}
if i := a.RemoteURL; i != "" {
apiAttachment.RemoteURL = &i
}
if i := a.Thumbnail.RemoteURL; i != "" {
apiAttachment.PreviewRemoteURL = &i
}
if i := a.Description; i != "" {
apiAttachment.Description = &i
}
// Type-specific fields.
switch a.Type {
case gtsmodel.FileTypeImage:
apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height)
apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect)
apiAttachment.Meta.Focus = &apimodel.MediaFocus{
X: a.FileMeta.Focus.X,
Y: a.FileMeta.Focus.Y,
}
case gtsmodel.FileTypeVideo, gtsmodel.FileTypeAudio:
if i := a.FileMeta.Original.Duration; i != nil {
apiAttachment.Meta.Original.Duration = *i
}
if i := a.FileMeta.Original.Framerate; i != nil {
// The masto api expects this as a string in
// the format `integer/1`, so 30fps is `30/1`.
round := math.Round(float64(*i))
fr := strconv.Itoa(int(round))
apiAttachment.Meta.Original.FrameRate = fr + "/1"
}
if i := a.FileMeta.Original.Bitrate; i != nil {
apiAttachment.Meta.Original.Bitrate = int(*i)
}
}
return apiAttachment, nil
return api, nil
}
// MentionToAPIMention converts a gts model mention into its api (frontend) representation for serialization on the API.
@ -681,6 +654,7 @@ func (c *Converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention
// EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API.
func (c *Converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) {
var category string
if e.CategoryID != "" {
if e.Category == nil {
var err error
@ -778,14 +752,15 @@ func (c *Converter) StatusToAPIStatus(
return nil, err
}
// Normalize status for the API by pruning
// out unknown attachment types and replacing
// them with a helpful message.
// Normalize status for API by pruning
// attachments that were not locally
// stored, replacing them with a helpful
// message + links to remote.
var aside string
aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments)
aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
apiStatus.Content += aside
if apiStatus.Reblog != nil {
aside, apiStatus.Reblog.MediaAttachments = placeholdUnknownAttachments(apiStatus.Reblog.MediaAttachments)
aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
apiStatus.Reblog.Content += aside
}
@ -962,15 +937,15 @@ func filterableTextFields(s *gtsmodel.Status) []string {
func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool {
switch filterContext {
case statusfilter.FilterContextHome:
return util.PtrValueOr(filter.ContextHome, false)
return util.PtrOrValue(filter.ContextHome, false)
case statusfilter.FilterContextNotifications:
return util.PtrValueOr(filter.ContextNotifications, false)
return util.PtrOrValue(filter.ContextNotifications, false)
case statusfilter.FilterContextPublic:
return util.PtrValueOr(filter.ContextPublic, false)
return util.PtrOrValue(filter.ContextPublic, false)
case statusfilter.FilterContextThread:
return util.PtrValueOr(filter.ContextThread, false)
return util.PtrOrValue(filter.ContextThread, false)
case statusfilter.FilterContextAccount:
return util.PtrValueOr(filter.ContextAccount, false)
return util.PtrOrValue(filter.ContextAccount, false)
}
return false
}
@ -2083,7 +2058,7 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
ID: filterKeyword.ID,
Phrase: filterKeyword.Keyword,
Context: filterToAPIFilterContexts(filter),
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
Irreversible: filter.Action == gtsmodel.FilterActionHide,
}, nil
@ -2121,19 +2096,19 @@ func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string {
func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
if util.PtrValueOr(filter.ContextHome, false) {
if util.PtrOrValue(filter.ContextHome, false) {
apiContexts = append(apiContexts, apimodel.FilterContextHome)
}
if util.PtrValueOr(filter.ContextNotifications, false) {
if util.PtrOrValue(filter.ContextNotifications, false) {
apiContexts = append(apiContexts, apimodel.FilterContextNotifications)
}
if util.PtrValueOr(filter.ContextPublic, false) {
if util.PtrOrValue(filter.ContextPublic, false) {
apiContexts = append(apiContexts, apimodel.FilterContextPublic)
}
if util.PtrValueOr(filter.ContextThread, false) {
if util.PtrOrValue(filter.ContextThread, false) {
apiContexts = append(apiContexts, apimodel.FilterContextThread)
}
if util.PtrValueOr(filter.ContextAccount, false) {
if util.PtrOrValue(filter.ContextAccount, false) {
apiContexts = append(apiContexts, apimodel.FilterContextAccount)
}
return apiContexts
@ -2154,7 +2129,7 @@ func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterK
return &apimodel.FilterKeyword{
ID: filterKeyword.ID,
Keyword: filterKeyword.Keyword,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),
}
}

View file

@ -851,7 +851,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e Note from localhost:8080: 2 attachments in this status could not be downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e",
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e",
"reblog": null,
"account": {
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
@ -1070,30 +1070,30 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
{
"id": "01HE7ZFX9GKA5ZZVD4FACABSS9",
"type": "unknown",
"url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
"text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
"preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg",
"url": null,
"text_url": null,
"preview_url": null,
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg",
"preview_remote_url": null,
"meta": null,
"description": "SVG line art of a sloth, public domain",
"blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of",
"Sensitive": true,
"MIMEType": "image/svg"
"MIMEType": ""
},
{
"id": "01HE88YG74PVAB81PX2XA9F3FG",
"type": "unknown",
"url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
"text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
"preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg",
"url": null,
"text_url": null,
"preview_url": null,
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",
"preview_remote_url": null,
"meta": null,
"description": "Jolly salsa song, public domain.",
"blurhash": null,
"Sensitive": true,
"MIMEType": "audio/mpeg"
"MIMEType": ""
}
],
"LanguageTag": "en",
@ -1357,13 +1357,19 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
"height": 404,
"frame_rate": "30/1",
"duration": 15.033334,
"bitrate": 1206522
"bitrate": 1206522,
"size": "720x404",
"aspect": 1.7821782
},
"small": {
"width": 720,
"height": 404,
"size": "720x404",
"aspect": 1.7821782
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "A cow adorably licking another cow!",

View file

@ -20,6 +20,7 @@ package typeutils
import (
"context"
"fmt"
"math"
"net/url"
"path"
"slices"
@ -35,6 +36,26 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/text"
)
// toAPISize converts a set of media dimensions
// to mastodon API compatible size string.
func toAPISize(width, height int) string {
return strconv.Itoa(width) +
"x" +
strconv.Itoa(height)
}
// toAPIFrameRate converts a media framerate ptr
// to mastodon API compatible framerate string.
func toAPIFrameRate(framerate *float32) string {
if framerate == nil {
return ""
}
// The masto api expects this as a string in
// the format `integer/1`, so 30fps is `30/1`.
round := math.Round(float64(*framerate))
return strconv.Itoa(int(round)) + "/1"
}
type statusInteractions struct {
Favourited bool
Muted bool
@ -92,7 +113,7 @@ func misskeyReportInlineURLs(content string) []*url.URL {
return urls
}
// placeholdUnknownAttachments separates any attachments with type `unknown`
// placeholderAttachments separates any attachments with missing local URL
// out of the given slice, and returns a piece of text containing links to
// those attachments, as well as the slice of remaining "known" attachments.
// If there are no unknown-type attachments in the provided slice, an empty
@ -104,62 +125,61 @@ func misskeyReportInlineURLs(content string) []*url.URL {
// Example:
//
// <hr>
// <p><i lang="en"> Note from your.instance.com: 2 attachments in this status could not be downloaded. Treat the following external links with care:</i></p>
// <p><i lang="en"> Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>
// <ul>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
// </ul>
func placeholdUnknownAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
// Extract unknown-type attachments into a separate
// slice, deleting them from arr in the process.
var unknowns []*apimodel.Attachment
arr = slices.DeleteFunc(arr, func(elem *apimodel.Attachment) bool {
unknown := elem.Type == "unknown"
if unknown {
// Set aside unknown-type attachment.
unknowns = append(unknowns, elem)
}
func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
return unknown
// Extract non-locally stored attachments into a
// separate slice, deleting them from input slice.
var nonLocal []*apimodel.Attachment
arr = slices.DeleteFunc(arr, func(elem *apimodel.Attachment) bool {
if elem.URL == nil {
nonLocal = append(nonLocal, elem)
return true
}
return false
})
unknownsLen := len(unknowns)
if unknownsLen == 0 {
// No unknown attachments,
// nothing to do.
if len(nonLocal) == 0 {
// No non-locally
// stored media.
return "", arr
}
// Plural / singular.
var (
attachments string
links string
)
if unknownsLen == 1 {
attachments = "1 attachment"
links = "link"
} else {
attachments = strconv.Itoa(unknownsLen) + " attachments"
links = "links"
}
var note strings.Builder
note.WriteString(`<hr>`)
note.WriteString(`<p><i lang="en">`)
note.WriteString(` Note from ` + config.GetHost() + `: ` + attachments + ` in this status could not be downloaded. Treat the following external ` + links + ` with care:`)
note.WriteString(`</i></p>`)
note.WriteString(`<ul>`)
for _, a := range unknowns {
var (
remoteURL = *a.RemoteURL
base = path.Base(remoteURL)
entry = fmt.Sprintf(`<a href="%s">%s</a>`, remoteURL, base)
)
note.WriteString(`<hr><p><i lang="en"> Note from `)
note.WriteString(config.GetHost())
note.WriteString(`: `)
note.WriteString(strconv.Itoa(len(nonLocal)))
if len(nonLocal) > 1 {
// Use plural word form.
note.WriteString(` attachments in this status were not downloaded. ` +
`Treat the following external links with care:`)
} else {
// Use singular word form.
note.WriteString(` attachment in this status was not downloaded. ` +
`Treat the following external link with care:`)
}
note.WriteString(`</i></p><ul>`)
for _, a := range nonLocal {
note.WriteString(`<li>`)
note.WriteString(`<a href="`)
note.WriteString(*a.RemoteURL)
note.WriteString(`">`)
note.WriteString(path.Base(*a.RemoteURL))
note.WriteString(`</a>`)
if d := a.Description; d != nil && *d != "" {
entry += ` [` + *d + `]`
note.WriteString(` [`)
note.WriteString(*d)
note.WriteString(`]`)
}
note.WriteString(`<li>` + entry + `</li>`)
note.WriteString(`</li>`)
}
note.WriteString(`</ul>`)

View file

@ -43,10 +43,19 @@ func PtrIf[T comparable](t T) *T {
return &t
}
// PtrValueOr returns either value of ptr, or default.
func PtrValueOr[T any](t *T, _default T) T {
// PtrOrZero returns either value of ptr, or zero.
func PtrOrZero[T any](t *T) T {
if t == nil {
var z T
return z
}
return *t
}
// PtrOrValue returns either contained value of ptr, or 'value'.
func PtrOrValue[T any](t *T, value T) T {
if t != nil {
return *t
}
return _default
return value
}

View file

@ -1188,20 +1188,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "SVG line art of a sloth, public domain",
Blurhash: "L26*j+~qE1RP?wxut7ofRlM{R*of",
Processing: 2,
File: gtsmodel.File{
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
ContentType: "image/svg",
FileSize: 147819,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg",
ContentType: "image/jpeg",
FileSize: 0,
URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
File: gtsmodel.File{},
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
"remote_account_2_status_1_attachment_3": {
ID: "01HE88YG74PVAB81PX2XA9F3FG",
@ -1216,20 +1207,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "Jolly salsa song, public domain.",
Blurhash: "",
Processing: 2,
File: gtsmodel.File{
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
ContentType: "audio/mpeg",
FileSize: 147819,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg",
ContentType: "image/jpeg",
FileSize: 0,
URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
File: gtsmodel.File{},
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
}
}