[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:
parent
0aadc2db2a
commit
72ba5666a6
|
@ -24,6 +24,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-logger/v2/level"
|
||||||
"codeberg.org/gruf/go-storage/memory"
|
"codeberg.org/gruf/go-storage/memory"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
||||||
|
@ -40,6 +41,8 @@ func main() {
|
||||||
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||||
defer cncl()
|
defer cncl()
|
||||||
|
|
||||||
|
log.SetLevel(level.INFO)
|
||||||
|
|
||||||
if len(os.Args) != 3 {
|
if len(os.Args) != 3 {
|
||||||
log.Panic(ctx, "Usage: go run ./cmd/process-emoji <input-file> <output-static>")
|
log.Panic(ctx, "Usage: go run ./cmd/process-emoji <input-file> <output-static>")
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-logger/v2/level"
|
||||||
"codeberg.org/gruf/go-storage/memory"
|
"codeberg.org/gruf/go-storage/memory"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
||||||
|
@ -39,6 +40,8 @@ func main() {
|
||||||
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||||
defer cncl()
|
defer cncl()
|
||||||
|
|
||||||
|
log.SetLevel(level.INFO)
|
||||||
|
|
||||||
if len(os.Args) != 4 {
|
if len(os.Args) != 4 {
|
||||||
log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")
|
log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,7 +138,7 @@ func (m *Module) AccountMutePOSTHandler(c *gin.Context) {
|
||||||
|
|
||||||
func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {
|
func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {
|
||||||
// Apply defaults for missing fields.
|
// 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.
|
// Normalize mute duration if necessary.
|
||||||
// If we parsed this as JSON, expires_in
|
// If we parsed this as JSON, expires_in
|
||||||
|
|
|
@ -40,8 +40,8 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults for missing fields.
|
// Apply defaults for missing fields.
|
||||||
form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
|
form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))
|
||||||
form.Irreversible = util.Ptr(util.PtrValueOr(form.Irreversible, false))
|
form.Irreversible = util.Ptr(util.PtrOrValue(form.Irreversible, false))
|
||||||
|
|
||||||
if *form.Irreversible {
|
if *form.Irreversible {
|
||||||
return errors.New("irreversible aka server-side drop filters are not supported yet")
|
return errors.New("irreversible aka server-side drop filters are not supported yet")
|
||||||
|
|
|
@ -100,7 +100,7 @@ func (suite *FiltersTestSuite) TestGetFilterKeyword() {
|
||||||
suite.NotEmpty(filterKeyword)
|
suite.NotEmpty(filterKeyword)
|
||||||
suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID)
|
suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID)
|
||||||
suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword)
|
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() {
|
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() {
|
||||||
|
|
|
@ -147,7 +147,7 @@ func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCrea
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
|
form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -192,7 +192,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
|
||||||
if err := validate.FilterTitle(form.Title); err != nil {
|
if err := validate.FilterTitle(form.Title); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn)
|
action := util.PtrOrValue(form.FilterAction, apimodel.FilterActionWarn)
|
||||||
if err := validate.FilterAction(action); err != nil {
|
if err := validate.FilterAction(action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -253,7 +253,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
|
||||||
if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {
|
if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {
|
||||||
return err
|
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 {
|
for _, formStatus := range form.Statuses {
|
||||||
if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil {
|
if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil {
|
||||||
|
|
|
@ -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
|
form.Keywords[i].Destroy = &destroy
|
||||||
|
|
||||||
if destroy && formKeyword.ID == nil {
|
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
|
form.Statuses[i].Destroy = &destroy
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
|
|
@ -78,12 +78,12 @@ func init() {
|
||||||
CreatedAt: account.CreatedAt,
|
CreatedAt: account.CreatedAt,
|
||||||
Reason: account.Reason,
|
Reason: account.Reason,
|
||||||
Privacy: newgtsmodel.Visibility(account.Privacy),
|
Privacy: newgtsmodel.Visibility(account.Privacy),
|
||||||
Sensitive: util.Ptr(util.PtrValueOr(account.Sensitive, false)),
|
Sensitive: util.Ptr(util.PtrOrValue(account.Sensitive, false)),
|
||||||
Language: account.Language,
|
Language: account.Language,
|
||||||
StatusContentType: account.StatusContentType,
|
StatusContentType: account.StatusContentType,
|
||||||
CustomCSS: account.CustomCSS,
|
CustomCSS: account.CustomCSS,
|
||||||
EnableRSS: util.Ptr(util.PtrValueOr(account.EnableRSS, false)),
|
EnableRSS: util.Ptr(util.PtrOrValue(account.EnableRSS, false)),
|
||||||
HideCollections: util.Ptr(util.PtrValueOr(account.HideCollections, false)),
|
HideCollections: util.Ptr(util.PtrOrValue(account.HideCollections, false)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the settings model.
|
// Insert the settings model.
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -30,7 +30,7 @@ type MediaAttachment struct {
|
||||||
StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached
|
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
|
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)
|
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
|
FileMeta FileMeta `bun:",embed:,notnull"` // Metadata about the file
|
||||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
|
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
|
||||||
Description string `bun:""` // Description of the attachment (for screenreaders)
|
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.
|
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.
|
// FileType refers to the file
|
||||||
type FileType string
|
// type of the media attaachment.
|
||||||
|
type FileType int
|
||||||
|
|
||||||
// MediaAttachment file types.
|
|
||||||
const (
|
const (
|
||||||
FileTypeImage FileType = "Image" // FileTypeImage is for jpegs, pngs, and standard gifs
|
// MediaAttachment file types.
|
||||||
FileTypeGifv FileType = "Gifv" // FileTypeGif is for soundless looping videos that behave like gifs
|
FileTypeUnknown FileType = 0 // FileTypeUnknown is for unknown file types (surprise surprise!)
|
||||||
FileTypeAudio FileType = "Audio" // FileTypeAudio is for audio-only files (no video)
|
FileTypeImage FileType = 1 // FileTypeImage is for jpegs, pngs, and standard gifs
|
||||||
FileTypeVideo FileType = "Video" // FileTypeVideo is for files with audio + visual
|
FileTypeAudio FileType = 2 // FileTypeAudio is for audio-only files (no video)
|
||||||
FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!)
|
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.
|
// FileMeta describes metadata about the actual contents of the file.
|
||||||
type FileMeta struct {
|
type FileMeta struct {
|
||||||
Original Original `bun:"embed:original_"`
|
Original Original `bun:"embed:original_"`
|
||||||
|
|
|
@ -34,14 +34,33 @@ type wasmInstancePool struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *wasmInstancePool) Init(ctx context.Context, sz int) error {
|
func (p *wasmInstancePool) Init(ctx context.Context, sz int) error {
|
||||||
p.pool = make(chan *wasm.Instance, sz)
|
// Initialize for first time
|
||||||
for i := 0; i < sz; i++ {
|
// to preload module into the
|
||||||
|
// wazero compilation cache.
|
||||||
inst, err := p.inst.New(ctx)
|
inst, err := p.inst.New(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.pool <- inst
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -102,74 +102,19 @@ func (m *Manager) CreateMedia(
|
||||||
) {
|
) {
|
||||||
now := time.Now()
|
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,
|
// Populate initial fields on the new media,
|
||||||
// leaving out fields with values we don't know
|
// leaving out fields with values we don't know
|
||||||
// yet. These will be overwritten as we go.
|
// yet. These will be overwritten as we go.
|
||||||
attachment := >smodel.MediaAttachment{
|
attachment := >smodel.MediaAttachment{
|
||||||
ID: id,
|
ID: id.NewULID(),
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
URL: url,
|
|
||||||
Type: gtsmodel.FileTypeUnknown,
|
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
|
Type: gtsmodel.FileTypeUnknown,
|
||||||
Processing: gtsmodel.ProcessingStatusReceived,
|
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),
|
Avatar: util.Ptr(false),
|
||||||
Header: util.Ptr(false),
|
Header: util.Ptr(false),
|
||||||
Cached: util.Ptr(false),
|
Cached: util.Ptr(false),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we were provided additional info
|
// Check if we were provided additional info
|
||||||
|
@ -252,42 +197,12 @@ func (m *Manager) CreateEmoji(
|
||||||
// Generate new ID.
|
// Generate new ID.
|
||||||
id := id.NewULID()
|
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 {
|
if domain == "" && info.URI == nil {
|
||||||
// Generate URI for local emoji.
|
// Generate URI for local emoji.
|
||||||
uri := uris.URIForEmoji(id)
|
uri := uris.URIForEmoji(id)
|
||||||
info.URI = &uri
|
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,
|
// Populate initial fields on the new emoji,
|
||||||
// leaving out fields with values we don't know
|
// leaving out fields with values we don't know
|
||||||
// yet. These will be overwritten as we go.
|
// yet. These will be overwritten as we go.
|
||||||
|
@ -295,9 +210,6 @@ func (m *Manager) CreateEmoji(
|
||||||
ID: id,
|
ID: id,
|
||||||
Shortcode: shortcode,
|
Shortcode: shortcode,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
ImageStaticURL: staticURL,
|
|
||||||
ImageStaticPath: staticPath,
|
|
||||||
ImageStaticContentType: "image/png",
|
|
||||||
Disabled: util.Ptr(false),
|
Disabled: util.Ptr(false),
|
||||||
VisibleInPicker: util.Ptr(true),
|
VisibleInPicker: util.Ptr(true),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
@ -327,12 +239,6 @@ func (m *Manager) RefreshEmoji(
|
||||||
*ProcessingEmoji,
|
*ProcessingEmoji,
|
||||||
error,
|
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
|
// Create references to old emoji image
|
||||||
// paths before they get updated with new
|
// paths before they get updated with new
|
||||||
// path ID. These are required for later
|
// path ID. These are required for later
|
||||||
|
@ -380,38 +286,6 @@ func (m *Manager) RefreshEmoji(
|
||||||
return rct, nil
|
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.
|
// Finally, create new emoji in database.
|
||||||
processingEmoji, err := m.createEmoji(ctx,
|
processingEmoji, err := m.createEmoji(ctx,
|
||||||
func(ctx context.Context, emoji *gtsmodel.Emoji) error {
|
func(ctx context.Context, emoji *gtsmodel.Emoji) error {
|
||||||
|
@ -425,8 +299,8 @@ func (m *Manager) RefreshEmoji(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the refreshed path ID used.
|
// Generate a new path ID to use instead.
|
||||||
processingEmoji.newPathID = newPathID
|
processingEmoji.newPathID = id.NewULID()
|
||||||
|
|
||||||
return processingEmoji, nil
|
return processingEmoji, nil
|
||||||
}
|
}
|
||||||
|
@ -441,6 +315,12 @@ func (m *Manager) createEmoji(
|
||||||
*ProcessingEmoji,
|
*ProcessingEmoji,
|
||||||
error,
|
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,
|
// Check if we have additional info to add to the emoji,
|
||||||
// and overwrite some of the emoji fields if so.
|
// and overwrite some of the emoji fields if so.
|
||||||
if info.URI != nil {
|
if info.URI != nil {
|
||||||
|
@ -475,6 +355,7 @@ func (m *Manager) createEmoji(
|
||||||
|
|
||||||
// Return wrapped emoji for later processing.
|
// Return wrapped emoji for later processing.
|
||||||
processingEmoji := &ProcessingEmoji{
|
processingEmoji := &ProcessingEmoji{
|
||||||
|
instAccID: instanceAcc.ID,
|
||||||
emoji: emoji,
|
emoji: emoji,
|
||||||
dataFn: data,
|
dataFn: data,
|
||||||
mgr: m,
|
mgr: m,
|
||||||
|
|
|
@ -358,11 +358,10 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
|
||||||
suite.Equal(processing.ID(), attachment.ID)
|
suite.Equal(processing.ID(), attachment.ID)
|
||||||
suite.Equal(accountID, attachment.AccountID)
|
suite.Equal(accountID, attachment.AccountID)
|
||||||
|
|
||||||
// file meta should be correctly derived from the image
|
|
||||||
suite.Zero(attachment.FileMeta)
|
suite.Zero(attachment.FileMeta)
|
||||||
suite.Equal("application/octet-stream", attachment.File.ContentType)
|
suite.Zero(attachment.File.ContentType)
|
||||||
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
|
suite.Zero(attachment.Thumbnail.ContentType)
|
||||||
suite.Empty(attachment.Blurhash)
|
suite.Zero(attachment.Blurhash)
|
||||||
|
|
||||||
// now make sure the attachment is in the database
|
// now make sure the attachment is in the database
|
||||||
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
|
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)
|
stored, err := suite.storage.Has(ctx, attachment.File.Path)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.False(stored)
|
suite.False(stored)
|
||||||
|
|
||||||
stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
|
stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.False(stored)
|
suite.False(stored)
|
||||||
|
|
|
@ -26,7 +26,6 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/regexes"
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/uris"
|
"github.com/superseriousbusiness/gotosocial/internal/uris"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
@ -36,6 +35,7 @@ import (
|
||||||
// various functions for retrieving data from the process.
|
// various functions for retrieving data from the process.
|
||||||
type ProcessingEmoji struct {
|
type ProcessingEmoji struct {
|
||||||
emoji *gtsmodel.Emoji // processing emoji details
|
emoji *gtsmodel.Emoji // processing emoji details
|
||||||
|
instAccID string // instance account ID
|
||||||
newPathID string // new emoji path ID to use when being refreshed
|
newPathID string // new emoji path ID to use when being refreshed
|
||||||
dataFn DataFunc // load-data function, returns media stream
|
dataFn DataFunc // load-data function, returns media stream
|
||||||
done bool // done is set when process finishes with non ctx canceled type error
|
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
|
pathID = p.emoji.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine instance account ID from generated image static path.
|
// Calculate final emoji media file 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.
|
|
||||||
p.emoji.ImagePath = uris.StoragePathForAttachment(
|
p.emoji.ImagePath = uris.StoragePathForAttachment(
|
||||||
instanceAccID,
|
p.instAccID,
|
||||||
string(TypeEmoji),
|
string(TypeEmoji),
|
||||||
string(SizeOriginal),
|
string(SizeOriginal),
|
||||||
pathID,
|
pathID,
|
||||||
ext,
|
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.
|
// Copy temporary file into storage at path.
|
||||||
filesz, err := p.mgr.state.Storage.PutFile(ctx,
|
filesz, err := p.mgr.state.Storage.PutFile(ctx,
|
||||||
p.emoji.ImagePath,
|
p.emoji.ImagePath,
|
||||||
|
@ -228,19 +231,31 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
|
||||||
p.emoji.ImageFileSize = int(filesz)
|
p.emoji.ImageFileSize = int(filesz)
|
||||||
p.emoji.ImageStaticFileSize = int(staticsz)
|
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(
|
p.emoji.ImageURL = uris.URIForAttachment(
|
||||||
instanceAccID,
|
p.instAccID,
|
||||||
string(TypeEmoji),
|
string(TypeEmoji),
|
||||||
string(SizeOriginal),
|
string(SizeOriginal),
|
||||||
pathID,
|
pathID,
|
||||||
ext,
|
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
|
// Get mimetype for the file container
|
||||||
// type, falling back to generic data.
|
// type, falling back to generic data.
|
||||||
p.emoji.ImageContentType = getMimeType(ext)
|
p.emoji.ImageContentType = getMimeType(ext)
|
||||||
|
|
||||||
|
// Set the known emoji static content type.
|
||||||
|
p.emoji.ImageStaticContentType = "image/png"
|
||||||
|
|
||||||
// We can now consider this cached.
|
// We can now consider this cached.
|
||||||
p.emoji.Cached = util.Ptr(true)
|
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.
|
// Ensure marked as not cached.
|
||||||
p.emoji.Cached = util.Ptr(false)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -248,6 +248,15 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
|
||||||
return gtserror.Newf("error generating thumb blurhash: %w", err)
|
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.
|
// Calculate final media attachment file path.
|
||||||
|
@ -285,8 +294,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
|
||||||
p.media.Thumbnail.FileSize = int(thumbsz)
|
p.media.Thumbnail.FileSize = int(thumbsz)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill in correct attachment
|
// Generate a media attachment URL.
|
||||||
// data now we've parsed it.
|
|
||||||
p.media.URL = uris.URIForAttachment(
|
p.media.URL = uris.URIForAttachment(
|
||||||
p.media.AccountID,
|
p.media.AccountID,
|
||||||
string(TypeAttachment),
|
string(TypeAttachment),
|
||||||
|
@ -295,10 +303,22 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
|
||||||
ext,
|
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
|
// Get mimetype for the file container
|
||||||
// type, falling back to generic data.
|
// type, falling back to generic data.
|
||||||
p.media.File.ContentType = getMimeType(ext)
|
p.media.File.ContentType = getMimeType(ext)
|
||||||
|
|
||||||
|
// Set the known thumbnail content type.
|
||||||
|
p.media.Thumbnail.ContentType = "image/jpeg"
|
||||||
|
|
||||||
// We can now consider this cached.
|
// We can now consider this cached.
|
||||||
p.media.Cached = util.Ptr(true)
|
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
|
// Also ensure marked as unknown and finished
|
||||||
// processing so gets inserted as placeholder URL.
|
// processing so gets inserted as placeholder URL.
|
||||||
p.media.Processing = gtsmodel.ProcessingStatusProcessed
|
p.media.Processing = gtsmodel.ProcessingStatusProcessed
|
||||||
|
|
|
@ -117,8 +117,8 @@ func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmode
|
||||||
if targetAccount.IsLocal() && !*targetAccount.Locked {
|
if targetAccount.IsLocal() && !*targetAccount.Locked {
|
||||||
rel.Requested = false
|
rel.Requested = false
|
||||||
rel.Following = true
|
rel.Following = true
|
||||||
rel.ShowingReblogs = util.PtrValueOr(fr.ShowReblogs, true)
|
rel.ShowingReblogs = util.PtrOrValue(fr.ShowReblogs, true)
|
||||||
rel.Notifying = util.PtrValueOr(fr.Notify, false)
|
rel.Notifying = util.PtrOrValue(fr.Notify, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle side effects async.
|
// Handle side effects async.
|
||||||
|
|
|
@ -325,8 +325,8 @@ func (p *Processor) emojiUpdateCopy(
|
||||||
|
|
||||||
// Attempt to create the new local emoji.
|
// Attempt to create the new local emoji.
|
||||||
emoji, errWithCode := p.createEmoji(ctx,
|
emoji, errWithCode := p.createEmoji(ctx,
|
||||||
util.PtrValueOr(shortcode, ""),
|
util.PtrOrValue(shortcode, ""),
|
||||||
util.PtrValueOr(categoryName, ""),
|
util.PtrOrValue(categoryName, ""),
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
|
|
|
@ -71,7 +71,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
|
||||||
FilterID: filter.ID,
|
FilterID: filter.ID,
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
Keyword: form.Phrase,
|
Keyword: form.Phrase,
|
||||||
WholeWord: util.Ptr(util.PtrValueOr(form.WholeWord, false)),
|
WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)),
|
||||||
}
|
}
|
||||||
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
|
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
|
||||||
|
|
||||||
|
|
|
@ -108,11 +108,11 @@ func (p *Processor) Update(
|
||||||
if expiresAt != filter.ExpiresAt {
|
if expiresAt != filter.ExpiresAt {
|
||||||
forbiddenFields = append(forbiddenFields, "expires_in")
|
forbiddenFields = append(forbiddenFields, "expires_in")
|
||||||
}
|
}
|
||||||
if contextHome != util.PtrValueOr(filter.ContextHome, false) ||
|
if contextHome != util.PtrOrValue(filter.ContextHome, false) ||
|
||||||
contextNotifications != util.PtrValueOr(filter.ContextNotifications, false) ||
|
contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) ||
|
||||||
contextPublic != util.PtrValueOr(filter.ContextPublic, false) ||
|
contextPublic != util.PtrOrValue(filter.ContextPublic, false) ||
|
||||||
contextThread != util.PtrValueOr(filter.ContextThread, false) ||
|
contextThread != util.PtrOrValue(filter.ContextThread, false) ||
|
||||||
contextAccount != util.PtrValueOr(filter.ContextAccount, false) {
|
contextAccount != util.PtrOrValue(filter.ContextAccount, false) {
|
||||||
forbiddenFields = append(forbiddenFields, "context")
|
forbiddenFields = append(forbiddenFields, "context")
|
||||||
}
|
}
|
||||||
if len(forbiddenFields) > 0 {
|
if len(forbiddenFields) > 0 {
|
||||||
|
@ -132,7 +132,7 @@ func (p *Processor) Update(
|
||||||
filter.ContextThread = &contextThread
|
filter.ContextThread = &contextThread
|
||||||
filter.ContextAccount = &contextAccount
|
filter.ContextAccount = &contextAccount
|
||||||
filterKeyword.Keyword = form.Phrase
|
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.
|
// We only want to update the relevant filter keyword.
|
||||||
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
|
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
|
||||||
|
|
|
@ -189,7 +189,7 @@ func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.Filter
|
||||||
FilterID: filter.ID,
|
FilterID: filter.ID,
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
Keyword: *formKeyword.Keyword,
|
Keyword: *formKeyword.Keyword,
|
||||||
WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)),
|
WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)),
|
||||||
}
|
}
|
||||||
filterKeywordsByID[filterKeyword.ID] = filterKeyword
|
filterKeywordsByID[filterKeyword.ID] = filterKeyword
|
||||||
// Don't need to set columns, as we're using all of them.
|
// Don't need to set columns, as we're using all of them.
|
||||||
|
|
|
@ -26,7 +26,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/gruf/go-bytesize"
|
"codeberg.org/gruf/go-bytesize"
|
||||||
|
@ -245,13 +244,9 @@ func NewFileStorage() (*Driver, error) {
|
||||||
// Load runtime configuration
|
// Load runtime configuration
|
||||||
basePath := config.GetStorageLocalBasePath()
|
basePath := config.GetStorageLocalBasePath()
|
||||||
|
|
||||||
// Use default disk config but with
|
// Use default disk config with
|
||||||
// increased write buffer size and
|
// increased write buffer size.
|
||||||
// 'exclusive' bit sets when creating
|
|
||||||
// files to ensure we don't overwrite
|
|
||||||
// existing files unless intending to.
|
|
||||||
diskCfg := disk.DefaultConfig()
|
diskCfg := disk.DefaultConfig()
|
||||||
diskCfg.OpenWrite.Flags |= syscall.O_EXCL
|
|
||||||
diskCfg.WriteBufSize = int(16 * bytesize.KiB)
|
diskCfg.WriteBufSize = int(16 * bytesize.KiB)
|
||||||
|
|
||||||
// Open the disk storage implementation
|
// Open the disk storage implementation
|
||||||
|
|
|
@ -21,8 +21,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -321,9 +319,9 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
locked = util.PtrValueOr(a.Locked, true)
|
locked = util.PtrOrValue(a.Locked, true)
|
||||||
discoverable = util.PtrValueOr(a.Discoverable, false)
|
discoverable = util.PtrOrValue(a.Discoverable, false)
|
||||||
bot = util.PtrValueOr(a.Bot, false)
|
bot = util.PtrOrValue(a.Bot, false)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Remaining properties are simple and
|
// 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.
|
// 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) {
|
func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, media *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
|
||||||
apiAttachment := apimodel.Attachment{
|
var api apimodel.Attachment
|
||||||
ID: a.ID,
|
api.Type = media.Type.String()
|
||||||
Type: strings.ToLower(string(a.Type)),
|
api.ID = media.ID
|
||||||
|
|
||||||
|
// 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)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't try to serialize meta for
|
// Copy over local file URL.
|
||||||
// unknown attachments, there's no point.
|
api.URL = util.Ptr(media.URL)
|
||||||
if a.Type != gtsmodel.FileTypeUnknown {
|
api.TextURL = util.Ptr(media.URL)
|
||||||
apiAttachment.Meta = &apimodel.MediaMeta{
|
|
||||||
Original: apimodel.MediaDimensions{
|
// Set file focus details.
|
||||||
Width: a.FileMeta.Original.Width,
|
// (this doesn't make much sense if media
|
||||||
Height: a.FileMeta.Original.Height,
|
// has no image, but the API doesn't yet
|
||||||
},
|
// distinguish between zero values vs. none).
|
||||||
Small: apimodel.MediaDimensions{
|
api.Meta.Focus = new(apimodel.MediaFocus)
|
||||||
Width: a.FileMeta.Small.Width,
|
api.Meta.Focus.X = media.FileMeta.Focus.X
|
||||||
Height: a.FileMeta.Small.Height,
|
api.Meta.Focus.Y = media.FileMeta.Focus.Y
|
||||||
Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height),
|
|
||||||
Aspect: float32(a.FileMeta.Small.Aspect),
|
// 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 != "" {
|
// Set remaining API attachment fields.
|
||||||
apiAttachment.Blurhash = &i
|
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 != "" {
|
return api, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MentionToAPIMention converts a gts model mention into its api (frontend) representation for serialization on the API.
|
// 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.
|
// 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) {
|
func (c *Converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) {
|
||||||
var category string
|
var category string
|
||||||
|
|
||||||
if e.CategoryID != "" {
|
if e.CategoryID != "" {
|
||||||
if e.Category == nil {
|
if e.Category == nil {
|
||||||
var err error
|
var err error
|
||||||
|
@ -778,14 +752,15 @@ func (c *Converter) StatusToAPIStatus(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize status for the API by pruning
|
// Normalize status for API by pruning
|
||||||
// out unknown attachment types and replacing
|
// attachments that were not locally
|
||||||
// them with a helpful message.
|
// stored, replacing them with a helpful
|
||||||
|
// message + links to remote.
|
||||||
var aside string
|
var aside string
|
||||||
aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments)
|
aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
|
||||||
apiStatus.Content += aside
|
apiStatus.Content += aside
|
||||||
if apiStatus.Reblog != nil {
|
if apiStatus.Reblog != nil {
|
||||||
aside, apiStatus.Reblog.MediaAttachments = placeholdUnknownAttachments(apiStatus.Reblog.MediaAttachments)
|
aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
|
||||||
apiStatus.Reblog.Content += aside
|
apiStatus.Reblog.Content += aside
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -962,15 +937,15 @@ func filterableTextFields(s *gtsmodel.Status) []string {
|
||||||
func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool {
|
func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool {
|
||||||
switch filterContext {
|
switch filterContext {
|
||||||
case statusfilter.FilterContextHome:
|
case statusfilter.FilterContextHome:
|
||||||
return util.PtrValueOr(filter.ContextHome, false)
|
return util.PtrOrValue(filter.ContextHome, false)
|
||||||
case statusfilter.FilterContextNotifications:
|
case statusfilter.FilterContextNotifications:
|
||||||
return util.PtrValueOr(filter.ContextNotifications, false)
|
return util.PtrOrValue(filter.ContextNotifications, false)
|
||||||
case statusfilter.FilterContextPublic:
|
case statusfilter.FilterContextPublic:
|
||||||
return util.PtrValueOr(filter.ContextPublic, false)
|
return util.PtrOrValue(filter.ContextPublic, false)
|
||||||
case statusfilter.FilterContextThread:
|
case statusfilter.FilterContextThread:
|
||||||
return util.PtrValueOr(filter.ContextThread, false)
|
return util.PtrOrValue(filter.ContextThread, false)
|
||||||
case statusfilter.FilterContextAccount:
|
case statusfilter.FilterContextAccount:
|
||||||
return util.PtrValueOr(filter.ContextAccount, false)
|
return util.PtrOrValue(filter.ContextAccount, false)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -2083,7 +2058,7 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
|
||||||
ID: filterKeyword.ID,
|
ID: filterKeyword.ID,
|
||||||
Phrase: filterKeyword.Keyword,
|
Phrase: filterKeyword.Keyword,
|
||||||
Context: filterToAPIFilterContexts(filter),
|
Context: filterToAPIFilterContexts(filter),
|
||||||
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
|
WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),
|
||||||
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
|
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
|
||||||
Irreversible: filter.Action == gtsmodel.FilterActionHide,
|
Irreversible: filter.Action == gtsmodel.FilterActionHide,
|
||||||
}, nil
|
}, nil
|
||||||
|
@ -2121,19 +2096,19 @@ func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string {
|
||||||
|
|
||||||
func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
|
func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
|
||||||
apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
|
apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
|
||||||
if util.PtrValueOr(filter.ContextHome, false) {
|
if util.PtrOrValue(filter.ContextHome, false) {
|
||||||
apiContexts = append(apiContexts, apimodel.FilterContextHome)
|
apiContexts = append(apiContexts, apimodel.FilterContextHome)
|
||||||
}
|
}
|
||||||
if util.PtrValueOr(filter.ContextNotifications, false) {
|
if util.PtrOrValue(filter.ContextNotifications, false) {
|
||||||
apiContexts = append(apiContexts, apimodel.FilterContextNotifications)
|
apiContexts = append(apiContexts, apimodel.FilterContextNotifications)
|
||||||
}
|
}
|
||||||
if util.PtrValueOr(filter.ContextPublic, false) {
|
if util.PtrOrValue(filter.ContextPublic, false) {
|
||||||
apiContexts = append(apiContexts, apimodel.FilterContextPublic)
|
apiContexts = append(apiContexts, apimodel.FilterContextPublic)
|
||||||
}
|
}
|
||||||
if util.PtrValueOr(filter.ContextThread, false) {
|
if util.PtrOrValue(filter.ContextThread, false) {
|
||||||
apiContexts = append(apiContexts, apimodel.FilterContextThread)
|
apiContexts = append(apiContexts, apimodel.FilterContextThread)
|
||||||
}
|
}
|
||||||
if util.PtrValueOr(filter.ContextAccount, false) {
|
if util.PtrOrValue(filter.ContextAccount, false) {
|
||||||
apiContexts = append(apiContexts, apimodel.FilterContextAccount)
|
apiContexts = append(apiContexts, apimodel.FilterContextAccount)
|
||||||
}
|
}
|
||||||
return apiContexts
|
return apiContexts
|
||||||
|
@ -2154,7 +2129,7 @@ func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterK
|
||||||
return &apimodel.FilterKeyword{
|
return &apimodel.FilterKeyword{
|
||||||
ID: filterKeyword.ID,
|
ID: filterKeyword.ID,
|
||||||
Keyword: filterKeyword.Keyword,
|
Keyword: filterKeyword.Keyword,
|
||||||
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
|
WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -851,7 +851,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
|
||||||
"muted": false,
|
"muted": false,
|
||||||
"bookmarked": false,
|
"bookmarked": false,
|
||||||
"pinned": 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,
|
"reblog": null,
|
||||||
"account": {
|
"account": {
|
||||||
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
|
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
|
||||||
|
@ -1070,30 +1070,30 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
|
||||||
{
|
{
|
||||||
"id": "01HE7ZFX9GKA5ZZVD4FACABSS9",
|
"id": "01HE7ZFX9GKA5ZZVD4FACABSS9",
|
||||||
"type": "unknown",
|
"type": "unknown",
|
||||||
"url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
|
"url": null,
|
||||||
"text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
|
"text_url": null,
|
||||||
"preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg",
|
"preview_url": null,
|
||||||
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg",
|
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg",
|
||||||
"preview_remote_url": null,
|
"preview_remote_url": null,
|
||||||
"meta": null,
|
"meta": null,
|
||||||
"description": "SVG line art of a sloth, public domain",
|
"description": "SVG line art of a sloth, public domain",
|
||||||
"blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of",
|
"blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of",
|
||||||
"Sensitive": true,
|
"Sensitive": true,
|
||||||
"MIMEType": "image/svg"
|
"MIMEType": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "01HE88YG74PVAB81PX2XA9F3FG",
|
"id": "01HE88YG74PVAB81PX2XA9F3FG",
|
||||||
"type": "unknown",
|
"type": "unknown",
|
||||||
"url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
|
"url": null,
|
||||||
"text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
|
"text_url": null,
|
||||||
"preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg",
|
"preview_url": null,
|
||||||
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",
|
"remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",
|
||||||
"preview_remote_url": null,
|
"preview_remote_url": null,
|
||||||
"meta": null,
|
"meta": null,
|
||||||
"description": "Jolly salsa song, public domain.",
|
"description": "Jolly salsa song, public domain.",
|
||||||
"blurhash": null,
|
"blurhash": null,
|
||||||
"Sensitive": true,
|
"Sensitive": true,
|
||||||
"MIMEType": "audio/mpeg"
|
"MIMEType": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"LanguageTag": "en",
|
"LanguageTag": "en",
|
||||||
|
@ -1357,13 +1357,19 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
|
||||||
"height": 404,
|
"height": 404,
|
||||||
"frame_rate": "30/1",
|
"frame_rate": "30/1",
|
||||||
"duration": 15.033334,
|
"duration": 15.033334,
|
||||||
"bitrate": 1206522
|
"bitrate": 1206522,
|
||||||
|
"size": "720x404",
|
||||||
|
"aspect": 1.7821782
|
||||||
},
|
},
|
||||||
"small": {
|
"small": {
|
||||||
"width": 720,
|
"width": 720,
|
||||||
"height": 404,
|
"height": 404,
|
||||||
"size": "720x404",
|
"size": "720x404",
|
||||||
"aspect": 1.7821782
|
"aspect": 1.7821782
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "A cow adorably licking another cow!",
|
"description": "A cow adorably licking another cow!",
|
||||||
|
|
|
@ -20,6 +20,7 @@ package typeutils
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
@ -35,6 +36,26 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
"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 {
|
type statusInteractions struct {
|
||||||
Favourited bool
|
Favourited bool
|
||||||
Muted bool
|
Muted bool
|
||||||
|
@ -92,7 +113,7 @@ func misskeyReportInlineURLs(content string) []*url.URL {
|
||||||
return urls
|
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
|
// 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.
|
// those attachments, as well as the slice of remaining "known" attachments.
|
||||||
// If there are no unknown-type attachments in the provided slice, an empty
|
// If there are no unknown-type attachments in the provided slice, an empty
|
||||||
|
@ -104,62 +125,61 @@ func misskeyReportInlineURLs(content string) []*url.URL {
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// <hr>
|
// <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>
|
// <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/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>
|
// <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>
|
// </ul>
|
||||||
func placeholdUnknownAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
|
func placeholderAttachments(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 len(nonLocal) == 0 {
|
||||||
if unknownsLen == 0 {
|
// No non-locally
|
||||||
// No unknown attachments,
|
// stored media.
|
||||||
// nothing to do.
|
|
||||||
return "", arr
|
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
|
var note strings.Builder
|
||||||
note.WriteString(`<hr>`)
|
note.WriteString(`<hr>`)
|
||||||
note.WriteString(`<p><i lang="en">`)
|
note.WriteString(`<hr><p><i lang="en">ℹ️ Note from `)
|
||||||
note.WriteString(`ℹ️ Note from ` + config.GetHost() + `: ` + attachments + ` in this status could not be downloaded. Treat the following external ` + links + ` with care:`)
|
note.WriteString(config.GetHost())
|
||||||
note.WriteString(`</i></p>`)
|
note.WriteString(`: `)
|
||||||
note.WriteString(`<ul>`)
|
note.WriteString(strconv.Itoa(len(nonLocal)))
|
||||||
for _, a := range unknowns {
|
|
||||||
var (
|
if len(nonLocal) > 1 {
|
||||||
remoteURL = *a.RemoteURL
|
// Use plural word form.
|
||||||
base = path.Base(remoteURL)
|
note.WriteString(` attachments in this status were not downloaded. ` +
|
||||||
entry = fmt.Sprintf(`<a href="%s">%s</a>`, remoteURL, base)
|
`Treat the following external links with care:`)
|
||||||
)
|
} else {
|
||||||
if d := a.Description; d != nil && *d != "" {
|
// Use singular word form.
|
||||||
entry += ` [` + *d + `]`
|
note.WriteString(` attachment in this status was not downloaded. ` +
|
||||||
|
`Treat the following external link with care:`)
|
||||||
}
|
}
|
||||||
note.WriteString(`<li>` + entry + `</li>`)
|
|
||||||
|
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 != "" {
|
||||||
|
note.WriteString(` [`)
|
||||||
|
note.WriteString(*d)
|
||||||
|
note.WriteString(`]`)
|
||||||
|
}
|
||||||
|
note.WriteString(`</li>`)
|
||||||
}
|
}
|
||||||
note.WriteString(`</ul>`)
|
note.WriteString(`</ul>`)
|
||||||
|
|
||||||
|
|
|
@ -43,10 +43,19 @@ func PtrIf[T comparable](t T) *T {
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// PtrValueOr returns either value of ptr, or default.
|
// PtrOrZero returns either value of ptr, or zero.
|
||||||
func PtrValueOr[T any](t *T, _default T) T {
|
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 {
|
if t != nil {
|
||||||
return *t
|
return *t
|
||||||
}
|
}
|
||||||
return _default
|
return value
|
||||||
}
|
}
|
||||||
|
|
|
@ -1188,17 +1188,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
|
||||||
Description: "SVG line art of a sloth, public domain",
|
Description: "SVG line art of a sloth, public domain",
|
||||||
Blurhash: "L26*j+~qE1RP?wxut7ofRlM{R*of",
|
Blurhash: "L26*j+~qE1RP?wxut7ofRlM{R*of",
|
||||||
Processing: 2,
|
Processing: 2,
|
||||||
File: gtsmodel.File{
|
File: gtsmodel.File{},
|
||||||
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg",
|
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
|
||||||
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),
|
Avatar: util.Ptr(false),
|
||||||
Header: util.Ptr(false),
|
Header: util.Ptr(false),
|
||||||
Cached: util.Ptr(false),
|
Cached: util.Ptr(false),
|
||||||
|
@ -1216,17 +1207,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
|
||||||
Description: "Jolly salsa song, public domain.",
|
Description: "Jolly salsa song, public domain.",
|
||||||
Blurhash: "",
|
Blurhash: "",
|
||||||
Processing: 2,
|
Processing: 2,
|
||||||
File: gtsmodel.File{
|
File: gtsmodel.File{},
|
||||||
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3",
|
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
|
||||||
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),
|
Avatar: util.Ptr(false),
|
||||||
Header: util.Ptr(false),
|
Header: util.Ptr(false),
|
||||||
Cached: util.Ptr(false),
|
Cached: util.Ptr(false),
|
||||||
|
|
Loading…
Reference in New Issue