mirror of
				https://github.com/superseriousbusiness/gotosocial
				synced 2025-06-05 21:59:39 +02:00 
			
		
		
		
	[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:
		| @@ -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 | ||||||
| 		inst, err := p.inst.New(ctx) | 	// wazero compilation cache. | ||||||
| 		if err != nil { | 	inst, err := p.inst.New(ctx) | ||||||
| 			return err | 	if err != nil { | ||||||
| 		} | 		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(), | ||||||
|  | 		AccountID:  accountID, | ||||||
|  | 		Type:       gtsmodel.FileTypeUnknown, | ||||||
|  | 		Processing: gtsmodel.ProcessingStatusReceived, | ||||||
|  | 		Avatar:     util.Ptr(false), | ||||||
|  | 		Header:     util.Ptr(false), | ||||||
|  | 		Cached:     util.Ptr(false), | ||||||
| 		CreatedAt:  now, | 		CreatedAt:  now, | ||||||
| 		UpdatedAt:  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 | 	// Check if we were provided additional info | ||||||
| @@ -252,56 +197,23 @@ 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. | ||||||
| 	emoji := >smodel.Emoji{ | 	emoji := >smodel.Emoji{ | ||||||
| 		ID:                     id, | 		ID:              id, | ||||||
| 		Shortcode:              shortcode, | 		Shortcode:       shortcode, | ||||||
| 		Domain:                 domain, | 		Domain:          domain, | ||||||
| 		ImageStaticURL:         staticURL, | 		Disabled:        util.Ptr(false), | ||||||
| 		ImageStaticPath:        staticPath, | 		VisibleInPicker: util.Ptr(true), | ||||||
| 		ImageStaticContentType: "image/png", | 		CreatedAt:       now, | ||||||
| 		Disabled:               util.Ptr(false), | 		UpdatedAt:       now, | ||||||
| 		VisibleInPicker:        util.Ptr(true), |  | ||||||
| 		CreatedAt:              now, |  | ||||||
| 		UpdatedAt:              now, |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Finally, create new emoji. | 	// Finally, create new emoji. | ||||||
| @@ -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,9 +355,10 @@ func (m *Manager) createEmoji( | |||||||
|  |  | ||||||
| 	// Return wrapped emoji for later processing. | 	// Return wrapped emoji for later processing. | ||||||
| 	processingEmoji := &ProcessingEmoji{ | 	processingEmoji := &ProcessingEmoji{ | ||||||
| 		emoji:  emoji, | 		instAccID: instanceAcc.ID, | ||||||
| 		dataFn: data, | 		emoji:     emoji, | ||||||
| 		mgr:    m, | 		dataFn:    data, | ||||||
|  | 		mgr:       m, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return processingEmoji, nil | 	return processingEmoji, nil | ||||||
|   | |||||||
| @@ -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 | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Don't try to serialize meta for | 	// Only add file details if | ||||||
| 	// unknown attachments, there's no point. | 	// we have stored locally. | ||||||
| 	if a.Type != gtsmodel.FileTypeUnknown { | 	if media.File.Path != "" { | ||||||
| 		apiAttachment.Meta = &apimodel.MediaMeta{ | 		api.Meta = new(apimodel.MediaMeta) | ||||||
| 			Original: apimodel.MediaDimensions{ | 		api.Meta.Original = apimodel.MediaDimensions{ | ||||||
| 				Width:  a.FileMeta.Original.Width, | 			Width:     media.FileMeta.Original.Width, | ||||||
| 				Height: a.FileMeta.Original.Height, | 			Height:    media.FileMeta.Original.Height, | ||||||
| 			}, | 			Aspect:    media.FileMeta.Original.Aspect, | ||||||
| 			Small: apimodel.MediaDimensions{ | 			Size:      toAPISize(media.FileMeta.Original.Width, media.FileMeta.Original.Height), | ||||||
| 				Width:  a.FileMeta.Small.Width, | 			FrameRate: toAPIFrameRate(media.FileMeta.Original.Framerate), | ||||||
| 				Height: a.FileMeta.Small.Height, | 			Duration:  util.PtrOrZero(media.FileMeta.Original.Duration), | ||||||
| 				Size:   strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height), | 			Bitrate:   int(util.PtrOrZero(media.FileMeta.Original.Bitrate)), | ||||||
| 				Aspect: float32(a.FileMeta.Small.Aspect), | 		} | ||||||
| 			}, |  | ||||||
|  | 		// 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 != "" { | 	// 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 { | ||||||
|  | 		// 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 != "" { | 		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>`) | 	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,20 +1188,11 @@ 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", | 			Avatar:      util.Ptr(false), | ||||||
| 				FileSize:    147819, | 			Header:      util.Ptr(false), | ||||||
| 			}, | 			Cached:      util.Ptr(false), | ||||||
| 			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), |  | ||||||
| 		}, | 		}, | ||||||
| 		"remote_account_2_status_1_attachment_3": { | 		"remote_account_2_status_1_attachment_3": { | ||||||
| 			ID:          "01HE88YG74PVAB81PX2XA9F3FG", | 			ID:          "01HE88YG74PVAB81PX2XA9F3FG", | ||||||
| @@ -1216,20 +1207,11 @@ 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", | 			Avatar:      util.Ptr(false), | ||||||
| 				FileSize:    147819, | 			Header:      util.Ptr(false), | ||||||
| 			}, | 			Cached:      util.Ptr(false), | ||||||
| 			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), |  | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user