diff options
| author | 2024-07-17 15:26:33 +0000 | |
|---|---|---|
| committer | 2024-07-17 15:26:33 +0000 | |
| commit | 72ba5666a6ffd06ccdfd2db8dacc47de7f777a4c (patch) | |
| tree | ac8c71af4f9a57c0233ffd30f8867d02616c46cc | |
| parent | [feature] Allow users to set default interaction policies per status visibili... (diff) | |
| download | gotosocial-72ba5666a6ffd06ccdfd2db8dacc47de7f777a4c.tar.xz | |
[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
29 files changed, 665 insertions, 395 deletions
diff --git a/cmd/process-emoji/main.go b/cmd/process-emoji/main.go index b06eb84f8..0e999503e 100644 --- a/cmd/process-emoji/main.go +++ b/cmd/process-emoji/main.go @@ -24,6 +24,7 @@ import (  	"os/signal"  	"syscall" +	"codeberg.org/gruf/go-logger/v2/level"  	"codeberg.org/gruf/go-storage/memory"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db/bundb" @@ -40,6 +41,8 @@ func main() {  	ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)  	defer cncl() +	log.SetLevel(level.INFO) +  	if len(os.Args) != 3 {  		log.Panic(ctx, "Usage: go run ./cmd/process-emoji <input-file> <output-static>")  	} diff --git a/cmd/process-media/main.go b/cmd/process-media/main.go index 096d718f9..7487917bf 100644 --- a/cmd/process-media/main.go +++ b/cmd/process-media/main.go @@ -24,6 +24,7 @@ import (  	"os/signal"  	"syscall" +	"codeberg.org/gruf/go-logger/v2/level"  	"codeberg.org/gruf/go-storage/memory"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db/bundb" @@ -39,6 +40,8 @@ func main() {  	ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)  	defer cncl() +	log.SetLevel(level.INFO) +  	if len(os.Args) != 4 {  		log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")  	} diff --git a/internal/api/client/accounts/mute.go b/internal/api/client/accounts/mute.go index 37cd3bbff..affb0f055 100644 --- a/internal/api/client/accounts/mute.go +++ b/internal/api/client/accounts/mute.go @@ -138,7 +138,7 @@ func (m *Module) AccountMutePOSTHandler(c *gin.Context) {  func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {  	// Apply defaults for missing fields. -	form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false)) +	form.Notifications = util.Ptr(util.PtrOrValue(form.Notifications, false))  	// Normalize mute duration if necessary.  	// If we parsed this as JSON, expires_in diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go index 550df54fa..cce00fdc4 100644 --- a/internal/api/client/filters/v1/validate.go +++ b/internal/api/client/filters/v1/validate.go @@ -40,8 +40,8 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1  	}  	// Apply defaults for missing fields. -	form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) -	form.Irreversible = util.Ptr(util.PtrValueOr(form.Irreversible, false)) +	form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) +	form.Irreversible = util.Ptr(util.PtrOrValue(form.Irreversible, false))  	if *form.Irreversible {  		return errors.New("irreversible aka server-side drop filters are not supported yet") diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go index a5d8754a6..13e90c0c2 100644 --- a/internal/api/client/filters/v2/filterkeywordget_test.go +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -100,7 +100,7 @@ func (suite *FiltersTestSuite) TestGetFilterKeyword() {  	suite.NotEmpty(filterKeyword)  	suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID)  	suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword) -	suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord) +	suite.Equal(util.PtrOrValue(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord)  }  func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { diff --git a/internal/api/client/filters/v2/filterkeywordpost.go b/internal/api/client/filters/v2/filterkeywordpost.go index fab7dc812..ba8f80135 100644 --- a/internal/api/client/filters/v2/filterkeywordpost.go +++ b/internal/api/client/filters/v2/filterkeywordpost.go @@ -147,7 +147,7 @@ func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCrea  		return err  	} -	form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) +	form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))  	return nil  } diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go index 732b81041..13270b1e5 100644 --- a/internal/api/client/filters/v2/filterpost.go +++ b/internal/api/client/filters/v2/filterpost.go @@ -192,7 +192,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {  	if err := validate.FilterTitle(form.Title); err != nil {  		return err  	} -	action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn) +	action := util.PtrOrValue(form.FilterAction, apimodel.FilterActionWarn)  	if err := validate.FilterAction(action); err != nil {  		return err  	} @@ -253,7 +253,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {  		if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {  			return err  		} -		form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)) +		form.Keywords[i].WholeWord = util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false))  	}  	for _, formStatus := range form.Statuses {  		if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil { diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go index cc3531838..c86dc36dc 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -289,7 +289,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {  			}  		} -		destroy := util.PtrValueOr(formKeyword.Destroy, false) +		destroy := util.PtrOrValue(formKeyword.Destroy, false)  		form.Keywords[i].Destroy = &destroy  		if destroy && formKeyword.ID == nil { @@ -305,7 +305,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {  			}  		} -		destroy := util.PtrValueOr(formStatus.Destroy, false) +		destroy := util.PtrOrValue(formStatus.Destroy, false)  		form.Statuses[i].Destroy = &destroy  		switch { diff --git a/internal/db/bundb/migrations/20240318115336_account_settings.go b/internal/db/bundb/migrations/20240318115336_account_settings.go index 25c64e826..3bf58e21e 100644 --- a/internal/db/bundb/migrations/20240318115336_account_settings.go +++ b/internal/db/bundb/migrations/20240318115336_account_settings.go @@ -78,12 +78,12 @@ func init() {  					CreatedAt:         account.CreatedAt,  					Reason:            account.Reason,  					Privacy:           newgtsmodel.Visibility(account.Privacy), -					Sensitive:         util.Ptr(util.PtrValueOr(account.Sensitive, false)), +					Sensitive:         util.Ptr(util.PtrOrValue(account.Sensitive, false)),  					Language:          account.Language,  					StatusContentType: account.StatusContentType,  					CustomCSS:         account.CustomCSS, -					EnableRSS:         util.Ptr(util.PtrValueOr(account.EnableRSS, false)), -					HideCollections:   util.Ptr(util.PtrValueOr(account.HideCollections, false)), +					EnableRSS:         util.Ptr(util.PtrOrValue(account.EnableRSS, false)), +					HideCollections:   util.Ptr(util.PtrOrValue(account.HideCollections, false)),  				}  				// Insert the settings model. diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go new file mode 100644 index 000000000..5f01f53ef --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go @@ -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) +	} +} diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go new file mode 100644 index 000000000..f4567ab25 --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go @@ -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 +} diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go new file mode 100644 index 000000000..471a5abd1 --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go @@ -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 +} diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go index 471a5abd1..eb792ae3b 100644 --- a/internal/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -30,7 +30,7 @@ type MediaAttachment struct {  	StatusID          string           `bun:"type:CHAR(26),nullzero"`                                      // ID of the status to which this is attached  	URL               string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on *this* server  	RemoteURL         string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on a remote server (empty for local media) -	Type              FileType         `bun:",notnull"`                                                    // Type of file (image/gifv/audio/video/unknown) +	Type              FileType         `bun:",notnull,default:0"`                                          // Type of file (image/gifv/audio/video/unknown)  	FileMeta          FileMeta         `bun:",embed:,notnull"`                                             // Metadata about the file  	AccountID         string           `bun:"type:CHAR(26),nullzero,notnull"`                              // To which account does this attachment belong  	Description       string           `bun:""`                                                            // Description of the attachment (for screenreaders) @@ -81,18 +81,34 @@ const (  	ProcessingStatusError      ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.  ) -// FileType refers to the file type of the media attaachment. -type FileType string +// FileType refers to the file +// type of the media attaachment. +type FileType int -// MediaAttachment file types.  const ( -	FileTypeImage   FileType = "Image"   // FileTypeImage is for jpegs, pngs, and standard gifs -	FileTypeGifv    FileType = "Gifv"    // FileTypeGif is for soundless looping videos that behave like gifs -	FileTypeAudio   FileType = "Audio"   // FileTypeAudio is for audio-only files (no video) -	FileTypeVideo   FileType = "Video"   // FileTypeVideo is for files with audio + visual -	FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!) +	// MediaAttachment file types. +	FileTypeUnknown FileType = 0 // FileTypeUnknown is for unknown file types (surprise surprise!) +	FileTypeImage   FileType = 1 // FileTypeImage is for jpegs, pngs, and standard gifs +	FileTypeAudio   FileType = 2 // FileTypeAudio is for audio-only files (no video) +	FileTypeVideo   FileType = 3 // FileTypeVideo is for files with audio + visual  ) +// String returns a stringified, frontend API compatible form of FileType. +func (t FileType) String() string { +	switch t { +	case FileTypeUnknown: +		return "unknown" +	case FileTypeImage: +		return "image" +	case FileTypeAudio: +		return "audio" +	case FileTypeVideo: +		return "video" +	default: +		panic("invalid filetype") +	} +} +  // FileMeta describes metadata about the actual contents of the file.  type FileMeta struct {  	Original Original `bun:"embed:original_"` diff --git a/internal/media/ffmpeg/pool.go b/internal/media/ffmpeg/pool.go index 9f6446be3..e63b10e69 100644 --- a/internal/media/ffmpeg/pool.go +++ b/internal/media/ffmpeg/pool.go @@ -34,14 +34,33 @@ type wasmInstancePool struct {  }  func (p *wasmInstancePool) Init(ctx context.Context, sz int) error { +	// Initialize for first time +	// to preload module into the +	// wazero compilation cache. +	inst, err := p.inst.New(ctx) +	if err != nil { +		return err +	} + +	// Clamp to 1. +	if sz <= 0 { +		sz = 1 +	} + +	// Allocate new pool instance channel.  	p.pool = make(chan *wasm.Instance, sz) -	for i := 0; i < sz; i++ { -		inst, err := p.inst.New(ctx) -		if err != nil { -			return err -		} -		p.pool <- inst + +	// 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  } diff --git a/internal/media/manager.go b/internal/media/manager.go index 82b066edc..13bcebe79 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -102,74 +102,19 @@ func (m *Manager) CreateMedia(  ) {  	now := time.Now() -	// Generate new ID. -	id := id.NewULID() - -	// Placeholder URL for attachment. -	url := uris.URIForAttachment( -		accountID, -		string(TypeAttachment), -		string(SizeOriginal), -		id, -		"unknown", -	) - -	// Placeholder storage path for attachment. -	path := uris.StoragePathForAttachment( -		accountID, -		string(TypeAttachment), -		string(SizeOriginal), -		id, -		"unknown", -	) - -	// Calculate attachment thumbnail file path -	thumbPath := uris.StoragePathForAttachment( -		accountID, -		string(TypeAttachment), -		string(SizeSmall), -		id, - -		// Always encode attachment -		// thumbnails as jpeg. -		"jpeg", -	) - -	// Calculate attachment thumbnail URL. -	thumbURL := uris.URIForAttachment( -		accountID, -		string(TypeAttachment), -		string(SizeSmall), -		id, - -		// Always encode attachment -		// thumbnails as jpeg. -		"jpeg", -	) -  	// Populate initial fields on the new media,  	// leaving out fields with values we don't know  	// yet. These will be overwritten as we go.  	attachment := >smodel.MediaAttachment{ -		ID:         id, -		CreatedAt:  now, -		UpdatedAt:  now, -		URL:        url, -		Type:       gtsmodel.FileTypeUnknown, +		ID:         id.NewULID(),  		AccountID:  accountID, +		Type:       gtsmodel.FileTypeUnknown,  		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), +		Avatar:     util.Ptr(false), +		Header:     util.Ptr(false), +		Cached:     util.Ptr(false), +		CreatedAt:  now, +		UpdatedAt:  now,  	}  	// Check if we were provided additional info @@ -252,56 +197,23 @@ func (m *Manager) CreateEmoji(  	// Generate new ID.  	id := id.NewULID() -	// Fetch the local instance account for emoji path generation. -	instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") -	if err != nil { -		return nil, gtserror.Newf("error fetching instance account: %w", err) -	} -  	if domain == "" && info.URI == nil {  		// Generate URI for local emoji.  		uri := uris.URIForEmoji(id)  		info.URI = &uri  	} -	// Generate static URL for attachment. -	staticURL := uris.URIForAttachment( -		instanceAcc.ID, -		string(TypeEmoji), -		string(SizeStatic), -		id, - -		// All static emojis -		// are encoded as png. -		"png", -	) - -	// Generate static image path for attachment. -	staticPath := uris.StoragePathForAttachment( -		instanceAcc.ID, -		string(TypeEmoji), -		string(SizeStatic), -		id, - -		// All static emojis -		// are encoded as png. -		"png", -	) -  	// Populate initial fields on the new emoji,  	// leaving out fields with values we don't know  	// yet. These will be overwritten as we go.  	emoji := >smodel.Emoji{ -		ID:                     id, -		Shortcode:              shortcode, -		Domain:                 domain, -		ImageStaticURL:         staticURL, -		ImageStaticPath:        staticPath, -		ImageStaticContentType: "image/png", -		Disabled:               util.Ptr(false), -		VisibleInPicker:        util.Ptr(true), -		CreatedAt:              now, -		UpdatedAt:              now, +		ID:              id, +		Shortcode:       shortcode, +		Domain:          domain, +		Disabled:        util.Ptr(false), +		VisibleInPicker: util.Ptr(true), +		CreatedAt:       now, +		UpdatedAt:       now,  	}  	// Finally, create new emoji. @@ -327,12 +239,6 @@ func (m *Manager) RefreshEmoji(  	*ProcessingEmoji,  	error,  ) { -	// Fetch the local instance account for emoji path generation. -	instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") -	if err != nil { -		return nil, gtserror.Newf("error fetching instance account: %w", err) -	} -  	// Create references to old emoji image  	// paths before they get updated with new  	// path ID. These are required for later @@ -380,38 +286,6 @@ func (m *Manager) RefreshEmoji(  		return rct, nil  	} -	// Use a new ID to create a new path -	// for the new images, to get around -	// needing to do cache invalidation. -	newPathID, err := id.NewRandomULID() -	if err != nil { -		return nil, gtserror.Newf("error generating newPathID for emoji refresh: %s", err) -	} - -	// Generate new static URL for emoji. -	emoji.ImageStaticURL = uris.URIForAttachment( -		instanceAcc.ID, -		string(TypeEmoji), -		string(SizeStatic), -		newPathID, - -		// All static emojis -		// are encoded as png. -		"png", -	) - -	// Generate new static image storage path for emoji. -	emoji.ImageStaticPath = uris.StoragePathForAttachment( -		instanceAcc.ID, -		string(TypeEmoji), -		string(SizeStatic), -		newPathID, - -		// All static emojis -		// are encoded as png. -		"png", -	) -  	// Finally, create new emoji in database.  	processingEmoji, err := m.createEmoji(ctx,  		func(ctx context.Context, emoji *gtsmodel.Emoji) error { @@ -425,8 +299,8 @@ func (m *Manager) RefreshEmoji(  		return nil, err  	} -	// Set the refreshed path ID used. -	processingEmoji.newPathID = newPathID +	// Generate a new path ID to use instead. +	processingEmoji.newPathID = id.NewULID()  	return processingEmoji, nil  } @@ -441,6 +315,12 @@ func (m *Manager) createEmoji(  	*ProcessingEmoji,  	error,  ) { +	// Fetch the local instance account for emoji path generation. +	instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") +	if err != nil { +		return nil, gtserror.Newf("error fetching instance account: %w", err) +	} +  	// Check if we have additional info to add to the emoji,  	// and overwrite some of the emoji fields if so.  	if info.URI != nil { @@ -475,9 +355,10 @@ func (m *Manager) createEmoji(  	// Return wrapped emoji for later processing.  	processingEmoji := &ProcessingEmoji{ -		emoji:  emoji, -		dataFn: data, -		mgr:    m, +		instAccID: instanceAcc.ID, +		emoji:     emoji, +		dataFn:    data, +		mgr:       m,  	}  	return processingEmoji, nil diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 24e0ddd1e..c908b2994 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -358,11 +358,10 @@ func (suite *ManagerTestSuite) TestPDFProcess() {  	suite.Equal(processing.ID(), attachment.ID)  	suite.Equal(accountID, attachment.AccountID) -	// file meta should be correctly derived from the image  	suite.Zero(attachment.FileMeta) -	suite.Equal("application/octet-stream", attachment.File.ContentType) -	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) -	suite.Empty(attachment.Blurhash) +	suite.Zero(attachment.File.ContentType) +	suite.Zero(attachment.Thumbnail.ContentType) +	suite.Zero(attachment.Blurhash)  	// now make sure the attachment is in the database  	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) @@ -376,7 +375,6 @@ func (suite *ManagerTestSuite) TestPDFProcess() {  	stored, err := suite.storage.Has(ctx, attachment.File.Path)  	suite.NoError(err)  	suite.False(stored) -  	stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)  	suite.NoError(err)  	suite.False(stored) diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index 996a3aa03..f4265759b 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -26,7 +26,6 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/log" -	"github.com/superseriousbusiness/gotosocial/internal/regexes"  	"github.com/superseriousbusiness/gotosocial/internal/storage"  	"github.com/superseriousbusiness/gotosocial/internal/uris"  	"github.com/superseriousbusiness/gotosocial/internal/util" @@ -36,6 +35,7 @@ import (  // various functions for retrieving data from the process.  type ProcessingEmoji struct {  	emoji     *gtsmodel.Emoji   // processing emoji details +	instAccID string            // instance account ID  	newPathID string            // new emoji path ID to use when being refreshed  	dataFn    DataFunc          // load-data function, returns media stream  	done      bool              // done is set when process finishes with non ctx canceled type error @@ -191,21 +191,24 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {  		pathID = p.emoji.ID  	} -	// Determine instance account ID from generated image static path. -	instanceAccID, ok := getInstanceAccountID(p.emoji.ImageStaticPath) -	if !ok { -		return gtserror.Newf("invalid emoji static path; no instance account id: %s", p.emoji.ImageStaticPath) -	} - -	// Calculate final media attachment file path. +	// Calculate final emoji media file path.  	p.emoji.ImagePath = uris.StoragePathForAttachment( -		instanceAccID, +		p.instAccID,  		string(TypeEmoji),  		string(SizeOriginal),  		pathID,  		ext,  	) +	// Calculate final emoji static media file path. +	p.emoji.ImageStaticPath = uris.StoragePathForAttachment( +		p.instAccID, +		string(TypeEmoji), +		string(SizeStatic), +		pathID, +		"png", +	) +  	// Copy temporary file into storage at path.  	filesz, err := p.mgr.state.Storage.PutFile(ctx,  		p.emoji.ImagePath, @@ -228,19 +231,31 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {  	p.emoji.ImageFileSize = int(filesz)  	p.emoji.ImageStaticFileSize = int(staticsz) -	// Fill in remaining emoji data now it's stored. +	// Generate an emoji media static URL.  	p.emoji.ImageURL = uris.URIForAttachment( -		instanceAccID, +		p.instAccID,  		string(TypeEmoji),  		string(SizeOriginal),  		pathID,  		ext,  	) +	// Generate an emoji image static URL. +	p.emoji.ImageStaticURL = uris.URIForAttachment( +		p.instAccID, +		string(TypeEmoji), +		string(SizeStatic), +		pathID, +		"png", +	) +  	// Get mimetype for the file container  	// type, falling back to generic data.  	p.emoji.ImageContentType = getMimeType(ext) +	// Set the known emoji static content type. +	p.emoji.ImageStaticContentType = "image/png" +  	// We can now consider this cached.  	p.emoji.Cached = util.Ptr(true) @@ -268,16 +283,16 @@ func (p *ProcessingEmoji) cleanup(ctx context.Context) {  		}  	} +	// Unset processor-calculated fields. +	p.emoji.ImageStaticContentType = "" +	p.emoji.ImageStaticFileSize = 0 +	p.emoji.ImageStaticPath = "" +	p.emoji.ImageStaticURL = "" +	p.emoji.ImageContentType = "" +	p.emoji.ImageFileSize = 0 +	p.emoji.ImagePath = "" +	p.emoji.ImageURL = "" +  	// Ensure marked as not cached.  	p.emoji.Cached = util.Ptr(false)  } - -// getInstanceAccountID determines the instance account ID from -// emoji static image storage path. returns false on failure. -func getInstanceAccountID(staticPath string) (string, bool) { -	matches := regexes.FilePath.FindStringSubmatch(staticPath) -	if len(matches) < 2 { -		return "", false -	} -	return matches[1], true -} diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index e5af46a2f..393f7d715 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -248,6 +248,15 @@ func (p *ProcessingMedia) store(ctx context.Context) error {  				return gtserror.Newf("error generating thumb blurhash: %w", err)  			}  		} + +		// Calculate final media attachment thumbnail path. +		p.media.Thumbnail.Path = uris.StoragePathForAttachment( +			p.media.AccountID, +			string(TypeAttachment), +			string(SizeSmall), +			p.media.ID, +			"jpeg", +		)  	}  	// Calculate final media attachment file path. @@ -285,8 +294,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error {  		p.media.Thumbnail.FileSize = int(thumbsz)  	} -	// Fill in correct attachment -	// data now we've parsed it. +	// Generate a media attachment URL.  	p.media.URL = uris.URIForAttachment(  		p.media.AccountID,  		string(TypeAttachment), @@ -295,10 +303,22 @@ func (p *ProcessingMedia) store(ctx context.Context) error {  		ext,  	) +	// Generate a media attachment thumbnail URL. +	p.media.Thumbnail.URL = uris.URIForAttachment( +		p.media.AccountID, +		string(TypeAttachment), +		string(SizeSmall), +		p.media.ID, +		"jpeg", +	) +  	// Get mimetype for the file container  	// type, falling back to generic data.  	p.media.File.ContentType = getMimeType(ext) +	// Set the known thumbnail content type. +	p.media.Thumbnail.ContentType = "image/jpeg" +  	// We can now consider this cached.  	p.media.Cached = util.Ptr(true) @@ -329,6 +349,18 @@ func (p *ProcessingMedia) cleanup(ctx context.Context) {  		}  	} +	// Unset all processor-calculated media fields. +	p.media.FileMeta.Original = gtsmodel.Original{} +	p.media.FileMeta.Small = gtsmodel.Small{} +	p.media.File.ContentType = "" +	p.media.File.FileSize = 0 +	p.media.File.Path = "" +	p.media.Thumbnail.FileSize = 0 +	p.media.Thumbnail.ContentType = "" +	p.media.Thumbnail.Path = "" +	p.media.Thumbnail.URL = "" +	p.media.URL = "" +  	// Also ensure marked as unknown and finished  	// processing so gets inserted as placeholder URL.  	p.media.Processing = gtsmodel.ProcessingStatusProcessed diff --git a/internal/processing/account/follow.go b/internal/processing/account/follow.go index 6c066d6a6..59de8834b 100644 --- a/internal/processing/account/follow.go +++ b/internal/processing/account/follow.go @@ -117,8 +117,8 @@ func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmode  	if targetAccount.IsLocal() && !*targetAccount.Locked {  		rel.Requested = false  		rel.Following = true -		rel.ShowingReblogs = util.PtrValueOr(fr.ShowReblogs, true) -		rel.Notifying = util.PtrValueOr(fr.Notify, false) +		rel.ShowingReblogs = util.PtrOrValue(fr.ShowReblogs, true) +		rel.Notifying = util.PtrOrValue(fr.Notify, false)  	}  	// Handle side effects async. diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index cf5bacef8..66193ccfe 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -325,8 +325,8 @@ func (p *Processor) emojiUpdateCopy(  	// Attempt to create the new local emoji.  	emoji, errWithCode := p.createEmoji(ctx, -		util.PtrValueOr(shortcode, ""), -		util.PtrValueOr(categoryName, ""), +		util.PtrOrValue(shortcode, ""), +		util.PtrOrValue(categoryName, ""),  		data,  	)  	if errWithCode != nil { diff --git a/internal/processing/filters/v1/create.go b/internal/processing/filters/v1/create.go index 4d8ffc3e1..18367dfce 100644 --- a/internal/processing/filters/v1/create.go +++ b/internal/processing/filters/v1/create.go @@ -71,7 +71,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form  		FilterID:  filter.ID,  		Filter:    filter,  		Keyword:   form.Phrase, -		WholeWord: util.Ptr(util.PtrValueOr(form.WholeWord, false)), +		WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)),  	}  	filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go index 2c2fe5574..81340b4be 100644 --- a/internal/processing/filters/v1/update.go +++ b/internal/processing/filters/v1/update.go @@ -108,11 +108,11 @@ func (p *Processor) Update(  		if expiresAt != filter.ExpiresAt {  			forbiddenFields = append(forbiddenFields, "expires_in")  		} -		if contextHome != util.PtrValueOr(filter.ContextHome, false) || -			contextNotifications != util.PtrValueOr(filter.ContextNotifications, false) || -			contextPublic != util.PtrValueOr(filter.ContextPublic, false) || -			contextThread != util.PtrValueOr(filter.ContextThread, false) || -			contextAccount != util.PtrValueOr(filter.ContextAccount, false) { +		if contextHome != util.PtrOrValue(filter.ContextHome, false) || +			contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) || +			contextPublic != util.PtrOrValue(filter.ContextPublic, false) || +			contextThread != util.PtrOrValue(filter.ContextThread, false) || +			contextAccount != util.PtrOrValue(filter.ContextAccount, false) {  			forbiddenFields = append(forbiddenFields, "context")  		}  		if len(forbiddenFields) > 0 { @@ -132,7 +132,7 @@ func (p *Processor) Update(  	filter.ContextThread = &contextThread  	filter.ContextAccount = &contextAccount  	filterKeyword.Keyword = form.Phrase -	filterKeyword.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) +	filterKeyword.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false))  	// We only want to update the relevant filter keyword.  	filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index d8297de38..0d443d58e 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -189,7 +189,7 @@ func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.Filter  			FilterID:  filter.ID,  			Filter:    filter,  			Keyword:   *formKeyword.Keyword, -			WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)), +			WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)),  		}  		filterKeywordsByID[filterKeyword.ID] = filterKeyword  		// Don't need to set columns, as we're using all of them. diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d05fe3519..508433c1a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -26,7 +26,6 @@ import (  	"net/url"  	"os"  	"path" -	"syscall"  	"time"  	"codeberg.org/gruf/go-bytesize" @@ -245,13 +244,9 @@ func NewFileStorage() (*Driver, error) {  	// Load runtime configuration  	basePath := config.GetStorageLocalBasePath() -	// Use default disk config but with -	// increased write buffer size and -	// 'exclusive' bit sets when creating -	// files to ensure we don't overwrite -	// existing files unless intending to. +	// Use default disk config with +	// increased write buffer size.  	diskCfg := disk.DefaultConfig() -	diskCfg.OpenWrite.Flags |= syscall.O_EXCL  	diskCfg.WriteBufSize = int(16 * bytesize.KiB)  	// Open the disk storage implementation diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 6350f3269..f11c4af21 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -21,8 +21,6 @@ import (  	"context"  	"errors"  	"fmt" -	"math" -	"strconv"  	"strings"  	"time" @@ -321,9 +319,9 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A  	}  	var ( -		locked       = util.PtrValueOr(a.Locked, true) -		discoverable = util.PtrValueOr(a.Discoverable, false) -		bot          = util.PtrValueOr(a.Bot, false) +		locked       = util.PtrOrValue(a.Locked, true) +		discoverable = util.PtrOrValue(a.Discoverable, false) +		bot          = util.PtrOrValue(a.Bot, false)  	)  	// Remaining properties are simple and @@ -565,84 +563,59 @@ func (c *Converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati  }  // AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API. -func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) { -	apiAttachment := apimodel.Attachment{ -		ID:   a.ID, -		Type: strings.ToLower(string(a.Type)), -	} +func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, media *gtsmodel.MediaAttachment) (apimodel.Attachment, error) { +	var api apimodel.Attachment +	api.Type = media.Type.String() +	api.ID = media.ID + +	// Only add file details if +	// we have stored locally. +	if media.File.Path != "" { +		api.Meta = new(apimodel.MediaMeta) +		api.Meta.Original = apimodel.MediaDimensions{ +			Width:     media.FileMeta.Original.Width, +			Height:    media.FileMeta.Original.Height, +			Aspect:    media.FileMeta.Original.Aspect, +			Size:      toAPISize(media.FileMeta.Original.Width, media.FileMeta.Original.Height), +			FrameRate: toAPIFrameRate(media.FileMeta.Original.Framerate), +			Duration:  util.PtrOrZero(media.FileMeta.Original.Duration), +			Bitrate:   int(util.PtrOrZero(media.FileMeta.Original.Bitrate)), +		} + +		// Copy over local file URL. +		api.URL = util.Ptr(media.URL) +		api.TextURL = util.Ptr(media.URL) + +		// Set file focus details. +		// (this doesn't make much sense if media +		// has no image, but the API doesn't yet +		// distinguish between zero values vs. none). +		api.Meta.Focus = new(apimodel.MediaFocus) +		api.Meta.Focus.X = media.FileMeta.Focus.X +		api.Meta.Focus.Y = media.FileMeta.Focus.Y + +		// Only add thumbnail details if +		// we have thumbnail stored locally. +		if media.Thumbnail.Path != "" { +			api.Meta.Small = apimodel.MediaDimensions{ +				Width:  media.FileMeta.Small.Width, +				Height: media.FileMeta.Small.Height, +				Aspect: media.FileMeta.Small.Aspect, +				Size:   toAPISize(media.FileMeta.Small.Width, media.FileMeta.Small.Height), +			} -	// Don't try to serialize meta for -	// unknown attachments, there's no point. -	if a.Type != gtsmodel.FileTypeUnknown { -		apiAttachment.Meta = &apimodel.MediaMeta{ -			Original: apimodel.MediaDimensions{ -				Width:  a.FileMeta.Original.Width, -				Height: a.FileMeta.Original.Height, -			}, -			Small: apimodel.MediaDimensions{ -				Width:  a.FileMeta.Small.Width, -				Height: a.FileMeta.Small.Height, -				Size:   strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height), -				Aspect: float32(a.FileMeta.Small.Aspect), -			}, +			// Copy over local thumbnail file URL. +			api.PreviewURL = util.Ptr(media.Thumbnail.URL)  		}  	} -	if i := a.Blurhash; i != "" { -		apiAttachment.Blurhash = &i -	} - -	if i := a.URL; i != "" { -		apiAttachment.URL = &i -		apiAttachment.TextURL = &i -	} - -	if i := a.Thumbnail.URL; i != "" { -		apiAttachment.PreviewURL = &i -	} - -	if i := a.RemoteURL; i != "" { -		apiAttachment.RemoteURL = &i -	} - -	if i := a.Thumbnail.RemoteURL; i != "" { -		apiAttachment.PreviewRemoteURL = &i -	} - -	if i := a.Description; i != "" { -		apiAttachment.Description = &i -	} - -	// Type-specific fields. -	switch a.Type { - -	case gtsmodel.FileTypeImage: -		apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height) -		apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect) -		apiAttachment.Meta.Focus = &apimodel.MediaFocus{ -			X: a.FileMeta.Focus.X, -			Y: a.FileMeta.Focus.Y, -		} - -	case gtsmodel.FileTypeVideo, gtsmodel.FileTypeAudio: -		if i := a.FileMeta.Original.Duration; i != nil { -			apiAttachment.Meta.Original.Duration = *i -		} +	// Set remaining API attachment fields. +	api.Blurhash = util.PtrIf(media.Blurhash) +	api.RemoteURL = util.PtrIf(media.RemoteURL) +	api.PreviewRemoteURL = util.PtrIf(media.Thumbnail.RemoteURL) +	api.Description = util.PtrIf(media.Description) -		if i := a.FileMeta.Original.Framerate; i != nil { -			// The masto api expects this as a string in -			// the format `integer/1`, so 30fps is `30/1`. -			round := math.Round(float64(*i)) -			fr := strconv.Itoa(int(round)) -			apiAttachment.Meta.Original.FrameRate = fr + "/1" -		} - -		if i := a.FileMeta.Original.Bitrate; i != nil { -			apiAttachment.Meta.Original.Bitrate = int(*i) -		} -	} - -	return apiAttachment, nil +	return api, nil  }  // MentionToAPIMention converts a gts model mention into its api (frontend) representation for serialization on the API. @@ -681,6 +654,7 @@ func (c *Converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention  // EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API.  func (c *Converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) {  	var category string +  	if e.CategoryID != "" {  		if e.Category == nil {  			var err error @@ -778,14 +752,15 @@ func (c *Converter) StatusToAPIStatus(  		return nil, err  	} -	// Normalize status for the API by pruning -	// out unknown attachment types and replacing -	// them with a helpful message. +	// Normalize status for API by pruning +	// attachments that were not locally +	// stored, replacing them with a helpful +	// message + links to remote.  	var aside string -	aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments) +	aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)  	apiStatus.Content += aside  	if apiStatus.Reblog != nil { -		aside, apiStatus.Reblog.MediaAttachments = placeholdUnknownAttachments(apiStatus.Reblog.MediaAttachments) +		aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)  		apiStatus.Reblog.Content += aside  	} @@ -962,15 +937,15 @@ func filterableTextFields(s *gtsmodel.Status) []string {  func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool {  	switch filterContext {  	case statusfilter.FilterContextHome: -		return util.PtrValueOr(filter.ContextHome, false) +		return util.PtrOrValue(filter.ContextHome, false)  	case statusfilter.FilterContextNotifications: -		return util.PtrValueOr(filter.ContextNotifications, false) +		return util.PtrOrValue(filter.ContextNotifications, false)  	case statusfilter.FilterContextPublic: -		return util.PtrValueOr(filter.ContextPublic, false) +		return util.PtrOrValue(filter.ContextPublic, false)  	case statusfilter.FilterContextThread: -		return util.PtrValueOr(filter.ContextThread, false) +		return util.PtrOrValue(filter.ContextThread, false)  	case statusfilter.FilterContextAccount: -		return util.PtrValueOr(filter.ContextAccount, false) +		return util.PtrOrValue(filter.ContextAccount, false)  	}  	return false  } @@ -2083,7 +2058,7 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor  		ID:           filterKeyword.ID,  		Phrase:       filterKeyword.Keyword,  		Context:      filterToAPIFilterContexts(filter), -		WholeWord:    util.PtrValueOr(filterKeyword.WholeWord, false), +		WholeWord:    util.PtrOrValue(filterKeyword.WholeWord, false),  		ExpiresAt:    filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),  		Irreversible: filter.Action == gtsmodel.FilterActionHide,  	}, nil @@ -2121,19 +2096,19 @@ func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string {  func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {  	apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues) -	if util.PtrValueOr(filter.ContextHome, false) { +	if util.PtrOrValue(filter.ContextHome, false) {  		apiContexts = append(apiContexts, apimodel.FilterContextHome)  	} -	if util.PtrValueOr(filter.ContextNotifications, false) { +	if util.PtrOrValue(filter.ContextNotifications, false) {  		apiContexts = append(apiContexts, apimodel.FilterContextNotifications)  	} -	if util.PtrValueOr(filter.ContextPublic, false) { +	if util.PtrOrValue(filter.ContextPublic, false) {  		apiContexts = append(apiContexts, apimodel.FilterContextPublic)  	} -	if util.PtrValueOr(filter.ContextThread, false) { +	if util.PtrOrValue(filter.ContextThread, false) {  		apiContexts = append(apiContexts, apimodel.FilterContextThread)  	} -	if util.PtrValueOr(filter.ContextAccount, false) { +	if util.PtrOrValue(filter.ContextAccount, false) {  		apiContexts = append(apiContexts, apimodel.FilterContextAccount)  	}  	return apiContexts @@ -2154,7 +2129,7 @@ func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterK  	return &apimodel.FilterKeyword{  		ID:        filterKeyword.ID,  		Keyword:   filterKeyword.Keyword, -		WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), +		WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),  	}  } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 9fd4cea46..e9f53e100 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -851,7 +851,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments    "muted": false,    "bookmarked": false,    "pinned": false, -  "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status could not be downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e", +  "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e",    "reblog": null,    "account": {      "id": "01FHMQX3GAABWSM0S2VZEC2SWC", @@ -1070,30 +1070,30 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {      {        "id": "01HE7ZFX9GKA5ZZVD4FACABSS9",        "type": "unknown", -      "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", -      "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", -      "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", +      "url": null, +      "text_url": null, +      "preview_url": null,        "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg",        "preview_remote_url": null,        "meta": null,        "description": "SVG line art of a sloth, public domain",        "blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of",        "Sensitive": true, -      "MIMEType": "image/svg" +      "MIMEType": ""      },      {        "id": "01HE88YG74PVAB81PX2XA9F3FG",        "type": "unknown", -      "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", -      "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", -      "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", +      "url": null, +      "text_url": null, +      "preview_url": null,        "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",        "preview_remote_url": null,        "meta": null,        "description": "Jolly salsa song, public domain.",        "blurhash": null,        "Sensitive": true, -      "MIMEType": "audio/mpeg" +      "MIMEType": ""      }    ],    "LanguageTag": "en", @@ -1357,13 +1357,19 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {        "height": 404,        "frame_rate": "30/1",        "duration": 15.033334, -      "bitrate": 1206522 +      "bitrate": 1206522, +      "size": "720x404", +      "aspect": 1.7821782      },      "small": {        "width": 720,        "height": 404,        "size": "720x404",        "aspect": 1.7821782 +    }, +    "focus": { +      "x": 0, +      "y": 0      }    },    "description": "A cow adorably licking another cow!", diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index d674bc150..f28cd2554 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -20,6 +20,7 @@ package typeutils  import (  	"context"  	"fmt" +	"math"  	"net/url"  	"path"  	"slices" @@ -35,6 +36,26 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/text"  ) +// toAPISize converts a set of media dimensions +// to mastodon API compatible size string. +func toAPISize(width, height int) string { +	return strconv.Itoa(width) + +		"x" + +		strconv.Itoa(height) +} + +// toAPIFrameRate converts a media framerate ptr +// to mastodon API compatible framerate string. +func toAPIFrameRate(framerate *float32) string { +	if framerate == nil { +		return "" +	} +	// The masto api expects this as a string in +	// the format `integer/1`, so 30fps is `30/1`. +	round := math.Round(float64(*framerate)) +	return strconv.Itoa(int(round)) + "/1" +} +  type statusInteractions struct {  	Favourited bool  	Muted      bool @@ -92,7 +113,7 @@ func misskeyReportInlineURLs(content string) []*url.URL {  	return urls  } -// placeholdUnknownAttachments separates any attachments with type `unknown` +// placeholderAttachments separates any attachments with missing local URL  // out of the given slice, and returns a piece of text containing links to  // those attachments, as well as the slice of remaining "known" attachments.  // If there are no unknown-type attachments in the provided slice, an empty @@ -104,62 +125,61 @@ func misskeyReportInlineURLs(content string) []*url.URL {  // Example:  //  //	<hr> -//	<p><i lang="en">ℹ️ Note from your.instance.com: 2 attachments in this status could not be downloaded. Treat the following external links with care:</i></p> +//	<p><i lang="en">ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>  //	<ul>  //	   <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>  //	   <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>  //	</ul> -func placeholdUnknownAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) { -	// Extract unknown-type attachments into a separate -	// slice, deleting them from arr in the process. -	var unknowns []*apimodel.Attachment +func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) { + +	// 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 { -		unknown := elem.Type == "unknown" -		if unknown { -			// Set aside unknown-type attachment. -			unknowns = append(unknowns, elem) +		if elem.URL == nil { +			nonLocal = append(nonLocal, elem) +			return true  		} - -		return unknown +		return false  	}) -	unknownsLen := len(unknowns) -	if unknownsLen == 0 { -		// No unknown attachments, -		// nothing to do. +	if len(nonLocal) == 0 { +		// No non-locally +		// stored media.  		return "", arr  	} -	// Plural / singular. -	var ( -		attachments string -		links       string -	) +	var note strings.Builder +	note.WriteString(`<hr>`) +	note.WriteString(`<hr><p><i lang="en">ℹ️ Note from `) +	note.WriteString(config.GetHost()) +	note.WriteString(`: `) +	note.WriteString(strconv.Itoa(len(nonLocal))) -	if unknownsLen == 1 { -		attachments = "1 attachment" -		links = "link" +	if len(nonLocal) > 1 { +		// Use plural word form. +		note.WriteString(` attachments in this status were not downloaded. ` + +			`Treat the following external links with care:`)  	} else { -		attachments = strconv.Itoa(unknownsLen) + " attachments" -		links = "links" +		// Use singular word form. +		note.WriteString(` attachment in this status was not downloaded. ` + +			`Treat the following external link with care:`)  	} -	var note strings.Builder -	note.WriteString(`<hr>`) -	note.WriteString(`<p><i lang="en">`) -	note.WriteString(`ℹ️ Note from ` + config.GetHost() + `: ` + attachments + ` in this status could not be downloaded. Treat the following external ` + links + ` with care:`) -	note.WriteString(`</i></p>`) -	note.WriteString(`<ul>`) -	for _, a := range unknowns { -		var ( -			remoteURL = *a.RemoteURL -			base      = path.Base(remoteURL) -			entry     = fmt.Sprintf(`<a href="%s">%s</a>`, remoteURL, base) -		) +	note.WriteString(`</i></p><ul>`) +	for _, a := range nonLocal { +		note.WriteString(`<li>`) +		note.WriteString(`<a href="`) +		note.WriteString(*a.RemoteURL) +		note.WriteString(`">`) +		note.WriteString(path.Base(*a.RemoteURL)) +		note.WriteString(`</a>`)  		if d := a.Description; d != nil && *d != "" { -			entry += ` [` + *d + `]` +			note.WriteString(` [`) +			note.WriteString(*d) +			note.WriteString(`]`)  		} -		note.WriteString(`<li>` + entry + `</li>`) +		note.WriteString(`</li>`)  	}  	note.WriteString(`</ul>`) diff --git a/internal/util/ptr.go b/internal/util/ptr.go index d7c30da85..8a89666c4 100644 --- a/internal/util/ptr.go +++ b/internal/util/ptr.go @@ -43,10 +43,19 @@ func PtrIf[T comparable](t T) *T {  	return &t  } -// PtrValueOr returns either value of ptr, or default. -func PtrValueOr[T any](t *T, _default T) T { +// PtrOrZero returns either value of ptr, or zero. +func PtrOrZero[T any](t *T) T { +	if t == nil { +		var z T +		return z +	} +	return *t +} + +// PtrOrValue returns either contained value of ptr, or 'value'. +func PtrOrValue[T any](t *T, value T) T {  	if t != nil {  		return *t  	} -	return _default +	return value  } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index efd4785a5..c0cf47b81 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1188,20 +1188,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {  			Description: "SVG line art of a sloth, public domain",  			Blurhash:    "L26*j+~qE1RP?wxut7ofRlM{R*of",  			Processing:  2, -			File: gtsmodel.File{ -				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", -				ContentType: "image/svg", -				FileSize:    147819, -			}, -			Thumbnail: gtsmodel.Thumbnail{ -				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", -				ContentType: "image/jpeg", -				FileSize:    0, -				URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", -			}, -			Avatar: util.Ptr(false), -			Header: util.Ptr(false), -			Cached: util.Ptr(false), +			File:        gtsmodel.File{}, +			Thumbnail:   gtsmodel.Thumbnail{RemoteURL: ""}, +			Avatar:      util.Ptr(false), +			Header:      util.Ptr(false), +			Cached:      util.Ptr(false),  		},  		"remote_account_2_status_1_attachment_3": {  			ID:          "01HE88YG74PVAB81PX2XA9F3FG", @@ -1216,20 +1207,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {  			Description: "Jolly salsa song, public domain.",  			Blurhash:    "",  			Processing:  2, -			File: gtsmodel.File{ -				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", -				ContentType: "audio/mpeg", -				FileSize:    147819, -			}, -			Thumbnail: gtsmodel.Thumbnail{ -				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", -				ContentType: "image/jpeg", -				FileSize:    0, -				URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", -			}, -			Avatar: util.Ptr(false), -			Header: util.Ptr(false), -			Cached: util.Ptr(false), +			File:        gtsmodel.File{}, +			Thumbnail:   gtsmodel.Thumbnail{RemoteURL: ""}, +			Avatar:      util.Ptr(false), +			Header:      util.Ptr(false), +			Cached:      util.Ptr(false),  		},  	}  }  | 
