Compare commits

...

6 commits

Author SHA1 Message Date
Victor Dyotte a22f32f469
Merge c8fb4c17f1 into fab7d17031 2024-10-19 16:38:32 +02:00
tobi fab7d17031
[bugfix] Fix filter title unique constraint (#3458) 2024-10-19 11:04:07 +02:00
tobi 0d0314b98d
[chore] Fix loop issue in streaming 🤦 (#3457) 2024-10-18 16:57:50 +02:00
tobi 602c858379
[chore] Thumbnail only first frame of animated media (#3448) 2024-10-18 15:44:08 +02:00
tobi ffc86f9092
[bugfix] Fix occasionally streaming empty messages (#3456) 2024-10-18 15:43:09 +02:00
vdyotte c8fb4c17f1
Feat: Add global instance CSS customization setting
Allow instance admins to add custom CSS that will affect
every page of their instance.

This is done with a new CustomCSS instance setting that
works pretty much exactly like the Users CustomCSS property.
This custom CSS is then requested for every page load.
User styles/themes take precedence over this CSS.
2024-09-25 00:26:56 -04:00
40 changed files with 537 additions and 84 deletions

View file

@ -167,3 +167,11 @@ Links to the set contact account and/or email address will appear on the footer
The selected **contact user** must be an active (not suspended) admin and/or moderator on the instance. The selected **contact user** must be an active (not suspended) admin and/or moderator on the instance.
If you're on a single-user instance and you give admin privileges to your main account, you can just fill in your own username here; you don't need to make a separate admin account just for this. If you're on a single-user instance and you give admin privileges to your main account, you can just fill in your own username here; you don't need to make a separate admin account just for this.
### Instance Custom CSS
custom CSS allows you to further customize the way your instance looks when visited through a browser.
This custom CSS will be applied to all pages of your instance. Users themes and CSS still take precedence over this customization.
See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS for your instance.

View file

@ -1550,6 +1550,10 @@ definitions:
$ref: '#/definitions/instanceV1Configuration' $ref: '#/definitions/instanceV1Configuration'
contact_account: contact_account:
$ref: '#/definitions/account' $ref: '#/definitions/account'
custom_css:
description: Custom CSS for the instance.
type: string
x-go-name: CustomCSS
debug: debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false. description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean type: boolean
@ -1730,6 +1734,10 @@ definitions:
$ref: '#/definitions/instanceV2Configuration' $ref: '#/definitions/instanceV2Configuration'
contact: contact:
$ref: '#/definitions/instanceV2Contact' $ref: '#/definitions/instanceV2Contact'
custom_css:
description: Instance Custom Css
type: string
x-go-name: CustomCSS
debug: debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false. description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean type: boolean

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View file

@ -175,6 +175,7 @@ func validateInstanceUpdate(form *apimodel.InstanceSettingsUpdateRequest) error
form.ContactEmail == nil && form.ContactEmail == nil &&
form.ShortDescription == nil && form.ShortDescription == nil &&
form.Description == nil && form.Description == nil &&
form.CustomCSS == nil &&
form.Terms == nil && form.Terms == nil &&
form.Avatar == nil && form.Avatar == nil &&
form.AvatarDescription == nil && form.AvatarDescription == nil &&

View file

@ -858,7 +858,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/small/`+instanceAccount.AvatarMediaAttachment.ID+`.webp",`+` "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/small/`+instanceAccount.AvatarMediaAttachment.ID+`.webp",`+`
"thumbnail_static_type": "image/webp", "thumbnail_static_type": "image/webp",
"thumbnail_description": "A bouncing little green peglin.", "thumbnail_description": "A bouncing little green peglin.",
"blurhash": "LE9801Rl4Yt5%dWCV]t5Dmoex?WC" "blurhash": "LF9Hm*Rl4Yt5.4RlRSt5IXkBxsj["
}`, string(instanceV2ThumbnailJson)) }`, string(instanceV2ThumbnailJson))
// double extra special bonus: now update the image description without changing the image // double extra special bonus: now update the image description without changing the image

View file

@ -35,6 +35,8 @@
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
var pingMsg = []byte("ping!")
// StreamGETHandler swagger:operation GET /api/v1/streaming streamGet // StreamGETHandler swagger:operation GET /api/v1/streaming streamGet
// //
// Initiate a websocket connection for live streaming of statuses and notifications. // Initiate a websocket connection for live streaming of statuses and notifications.
@ -389,40 +391,57 @@ func (m *Module) writeToWSConn(
) { ) {
for { for {
// Wrap context with timeout to send a ping. // Wrap context with timeout to send a ping.
pingctx, cncl := context.WithTimeout(ctx, ping) pingCtx, cncl := context.WithTimeout(ctx, ping)
// Block on receipt of msg. // Block and wait for
msg, ok := stream.Recv(pingctx) // one of the following:
//
// - receipt of msg
// - timeout of pingCtx
// - stream closed.
msg, haveMsg := stream.Recv(pingCtx)
// Check if cancel because ping. // If ping context has timed
pinged := (pingctx.Err() != nil) // out, we should send a ping.
//
// In any case cancel pingCtx
// as we're done with it.
shouldPing := (pingCtx.Err() != nil)
cncl() cncl()
switch { switch {
case !ok && pinged: case !haveMsg && !shouldPing:
// The ping context timed out! // We have no message and we shouldn't
l.Trace("writing websocket ping") // send a ping; this means the stream
// has been closed from the client's end,
// so there's nothing further to do here.
l.Trace("no message and we shouldn't ping, returning...")
return
// Wrapped context time-out, send a keep-alive "ping". case haveMsg:
if err := wsConn.WriteControl(websocket.PingMessage, nil, time.Time{}); err != nil { // We have a message to stream.
l.Debugf("error writing websocket ping: %v", err) l.Tracef("writing websocket message: %+v", msg)
break
}
case !ok: if err := wsConn.WriteJSON(msg); err != nil {
// Stream was // If there's an error writing then drop the
// closed. // connection, as client may have disappeared
// suddenly; they can reconnect if necessary.
l.Debugf("error writing websocket message: %v", err)
return return
} }
l.Trace("writing websocket message: %+v", msg) case shouldPing:
// We have no message but we do
// need to send a keep-alive ping.
l.Trace("writing websocket ping")
// Received a new message from the processor. if err := wsConn.WriteControl(websocket.PingMessage, pingMsg, time.Time{}); err != nil {
if err := wsConn.WriteJSON(msg); err != nil { // If there's an error writing then drop the
l.Debugf("error writing websocket message: %v", err) // connection, as client may have disappeared
break // suddenly; they can reconnect if necessary.
l.Debugf("error writing websocket ping: %v", err)
return
}
} }
} }
l.Debug("finished websocket write")
} }

View file

@ -33,6 +33,8 @@ type InstanceSettingsUpdateRequest struct {
ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"` ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"`
// Longer description of the instance, max 5,000 chars. HTML formatting accepted. // Longer description of the instance, max 5,000 chars. HTML formatting accepted.
Description *string `form:"description" json:"description" xml:"description"` Description *string `form:"description" json:"description" xml:"description"`
// Custom CSS for the instance.
CustomCSS *string `form:"custom_css" json:"custom_css,omitempty" xml:"custom_css"`
// Terms and conditions of the instance, max 5,000 chars. HTML formatting accepted. // Terms and conditions of the instance, max 5,000 chars. HTML formatting accepted.
Terms *string `form:"terms" json:"terms" xml:"terms"` Terms *string `form:"terms" json:"terms" xml:"terms"`
// Image to use as the instance thumbnail. // Image to use as the instance thumbnail.

View file

@ -38,6 +38,8 @@ type InstanceV1 struct {
// //
// This should be displayed on the 'about' page for an instance. // This should be displayed on the 'about' page for an instance.
Description string `json:"description"` Description string `json:"description"`
// Custom CSS for the instance.
CustomCSS string `json:"custom_css,omitempty"`
// Raw (unparsed) version of description. // Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"` DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance. // A shorter description of the instance.

View file

@ -53,6 +53,8 @@ type InstanceV2 struct {
Description string `json:"description"` Description string `json:"description"`
// Raw (unparsed) version of description. // Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"` DescriptionText string `json:"description_text,omitempty"`
// Instance Custom Css
CustomCSS string `json:"custom_css,omitempty"`
// Basic anonymous usage data for this instance. // Basic anonymous usage data for this instance.
Usage InstanceV2Usage `json:"usage"` Usage InstanceV2Usage `json:"usage"`
// An image used to represent this instance. // An image used to represent this instance.

View file

@ -247,6 +247,54 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
} }
} }
func (suite *FilterTestSuite) TestFilterTitleOverlap() {
var (
ctx = context.Background()
account1 = "01HNEJXCPRTJVJY9MV0VVHGD47"
account2 = "01JAG5BRJPJYA0FSA5HR2MMFJH"
)
// Create an empty filter for account 1.
account1filter1 := &gtsmodel.Filter{
ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
AccountID: account1,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
if err := suite.db.PutFilter(ctx, account1filter1); err != nil {
suite.FailNow("", "error putting account1filter1: %s", err)
}
// Create a filter for account 2 with
// the same title, should be no issue.
account2filter1 := &gtsmodel.Filter{
ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET",
AccountID: account2,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
if err := suite.db.PutFilter(ctx, account2filter1); err != nil {
suite.FailNow("", "error putting account2filter1: %s", err)
}
// Try to create another filter for
// account 1 with the same name as
// an existing filter of theirs.
account1filter2 := &gtsmodel.Filter{
ID: "01JAG5J8NYKQE2KYCD28Y4P05V",
AccountID: account1,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
err := suite.db.PutFilter(ctx, account1filter2)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("", "wanted ErrAlreadyExists, got %s", err)
}
}
func TestFilterTestSuite(t *testing.T) { func TestFilterTestSuite(t *testing.T) {
suite.Run(t, new(FilterTestSuite)) suite.Run(t, new(FilterTestSuite))
} }

View file

@ -20,7 +20,7 @@
import ( import (
"context" "context"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240126064004_add_filters"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )

View file

@ -0,0 +1,65 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"regexp"
"time"
)
// Filter stores a filter created by a local account.
type Filter 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
ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire.
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter.
Title string `bun:",nullzero,notnull,unique"` // The name of the filter.
Action string `bun:",nullzero,notnull"` // The action to take.
Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter.
Statuses []*FilterStatus `bun:"-"` // Statuses for this filter.
ContextHome *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
ContextNotifications *bool `bun:",nullzero,notnull,default:false"` // Apply filter to notifications.
ContextPublic *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
ContextThread *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing a status's associated thread.
ContextAccount *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing an account profile.
}
// FilterKeyword stores a single keyword to filter statuses against.
type FilterKeyword 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
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression
}
// FilterStatus stores a single status to filter.
type FilterStatus 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
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to.
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter.
}

View file

@ -0,0 +1,44 @@
// 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"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TEXT", bun.Ident("instances"), bun.Ident("custom_css"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("instances"), bun.Ident("custom_css"))
return err
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,131 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create the new filters table
// with the unique constraint
// set on AccountID + Title.
if _, err := tx.
NewCreateTable().
ModelTableExpr("new_filters").
Model((*gtsmodel.Filter)(nil)).
Exec(ctx); err != nil {
return err
}
// Explicitly specify columns to bring
// from old table to new, to avoid any
// potential Postgres shenanigans.
columns := []string{
"id",
"created_at",
"updated_at",
"expires_at",
"account_id",
"title",
"action",
"context_home",
"context_notifications",
"context_public",
"context_thread",
"context_account",
}
// Copy all data for existing
// filters to the new table.
if _, err := tx.
NewInsert().
Table("new_filters").
Table("filters").
Column(columns...).
Exec(ctx); err != nil {
return err
}
// Drop the old table.
if _, err := tx.
NewDropTable().
Table("filters").
Exec(ctx); err != nil {
return err
}
// Rename new table to old table.
if _, err := tx.
ExecContext(
ctx,
"ALTER TABLE ? RENAME TO ?",
bun.Ident("new_filters"),
bun.Ident("filters"),
); err != nil {
return err
}
// Index the new version
// of the filters table.
if _, err := tx.
NewCreateIndex().
Table("filters").
Index("filters_account_id_idx").
Column("account_id").
IfNotExists().
Exec(ctx); err != nil {
return err
}
if db.Dialect().Name() == dialect.PG {
// Rename "new_filters_pkey" from the
// new table to just "filters_pkey".
// This is only necessary on Postgres.
if _, err := tx.ExecContext(
ctx,
"ALTER TABLE ? RENAME CONSTRAINT ? TO ?",
bun.Ident("public.filters"),
bun.Safe("new_filters_pkey"),
bun.Safe("filters_pkey"),
); err != nil {
return err
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -30,8 +30,8 @@ type Filter struct {
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created 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 UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire. ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire.
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter. AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter.
Title string `bun:",nullzero,notnull,unique"` // The name of the filter. Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter.
Action FilterAction `bun:",nullzero,notnull"` // The action to take. Action FilterAction `bun:",nullzero,notnull"` // The action to take.
Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter. Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter.
Statuses []*FilterStatus `bun:"-"` // Statuses for this filter. Statuses []*FilterStatus `bun:"-"` // Statuses for this filter.

View file

@ -34,6 +34,7 @@ type Instance struct {
ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing). ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Description string `bun:""` // Longer description of this instance. Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing). DescriptionText string `bun:""` // Raw text version of long description (before parsing).
CustomCSS string `bun:",nullzero"` // Custom CSS for the instance.
Terms string `bun:""` // Terms and conditions of this instance. Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing). TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance ContactEmail string `bun:""` // Contact email address for this instance

View file

@ -78,23 +78,17 @@ func ffmpegGenerateWebpThumb(ctx context.Context, inpath, outpath string, width,
// (NOT as libwebp_anim). // (NOT as libwebp_anim).
"-codec:v", "libwebp", "-codec:v", "libwebp",
// Select thumb from first 7 frames. // Only one frame
// (in particular <= 7 reduced memory usage, marginally) "-frames:v", "1",
// (thumb filter: https://ffmpeg.org/ffmpeg-filters.html#thumbnail)
"-filter:v", "thumbnail=n=7,"+
// Scale to dimensions // Scale to dimensions
// (scale filter: https://ffmpeg.org/ffmpeg-filters.html#scale) // (scale filter: https://ffmpeg.org/ffmpeg-filters.html#scale)
"scale="+strconv.Itoa(width)+ "-filter:v", "scale="+strconv.Itoa(width)+":"+strconv.Itoa(height)+","+
":"+strconv.Itoa(height)+","+
// Attempt to use original pixel format // Attempt to use original pixel format
// (format filter: https://ffmpeg.org/ffmpeg-filters.html#format) // (format filter: https://ffmpeg.org/ffmpeg-filters.html#format)
"format=pix_fmts="+pixfmt, "format=pix_fmts="+pixfmt,
// Only one frame
"-frames:v", "1",
// Quality not specified, // Quality not specified,
// i.e. use default which // i.e. use default which
// should be 75% webp quality. // should be 75% webp quality.

View file

@ -428,7 +428,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcess() {
suite.Equal("video/mp4", attachment.File.ContentType) suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/webp", attachment.Thumbnail.ContentType) suite.Equal("image/webp", attachment.Thumbnail.ContentType)
suite.Equal(312453, attachment.File.FileSize) suite.Equal(312453, attachment.File.FileSize)
suite.Equal(5648, attachment.Thumbnail.FileSize) suite.Equal(5598, attachment.Thumbnail.FileSize)
suite.Equal("LgIYH}xtNsofxtfPW.j[_4axn+of", attachment.Blurhash) suite.Equal("LgIYH}xtNsofxtfPW.j[_4axn+of", attachment.Blurhash)
// now make sure the attachment is in the database // now make sure the attachment is in the database
@ -441,6 +441,71 @@ func (suite *ManagerTestSuite) TestSlothVineProcess() {
equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-mp4-thumbnail.webp") equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-mp4-thumbnail.webp")
} }
func (suite *ManagerTestSuite) TestAnimatedGifProcess() {
ctx := context.Background()
data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/clock-original.gif")
if err != nil {
panic(err)
}
return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
processing, err := suite.manager.CreateMedia(ctx,
accountID,
data,
media.AdditionalMediaInfo{},
)
suite.NoError(err)
suite.NotNil(processing)
// do a blocking call to fetch the attachment
attachment, err := processing.Load(ctx)
suite.NoError(err)
suite.NotNil(attachment)
// make sure it's got the stuff set on it that we expect
// the attachment ID and accountID we expect
suite.Equal(processing.ID(), attachment.ID)
suite.Equal(accountID, attachment.AccountID)
// file meta should be correctly derived from the image
suite.EqualValues(gtsmodel.Original{
Width: 528,
Height: 528,
Size: 278784,
Aspect: 1,
Duration: util.Ptr(float32(8.58)),
Framerate: util.Ptr(float32(16)),
Bitrate: util.Ptr(uint64(114092)),
}, attachment.FileMeta.Original)
suite.EqualValues(gtsmodel.Small{
Width: 512,
Height: 512,
Size: 262144,
Aspect: 1,
}, attachment.FileMeta.Small)
suite.Equal("image/gif", attachment.File.ContentType)
suite.Equal("image/webp", attachment.Thumbnail.ContentType)
suite.Equal(122364, attachment.File.FileSize)
suite.Equal(12962, attachment.Thumbnail.FileSize)
suite.Equal("LmKUZkt700ofoffQofj[00WBj[WB", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
// ensure the files contain the expected data.
equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/clock-processed.gif")
equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/clock-thumbnail.webp")
}
func (suite *ManagerTestSuite) TestLongerMp4Process() { func (suite *ManagerTestSuite) TestLongerMp4Process() {
ctx := context.Background() ctx := context.Background()
@ -488,8 +553,8 @@ func (suite *ManagerTestSuite) TestLongerMp4Process() {
suite.Equal("video/mp4", attachment.File.ContentType) suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/webp", attachment.Thumbnail.ContentType) suite.Equal("image/webp", attachment.Thumbnail.ContentType)
suite.Equal(109569, attachment.File.FileSize) suite.Equal(109569, attachment.File.FileSize)
suite.Equal(2976, attachment.Thumbnail.FileSize) suite.Equal(2958, attachment.Thumbnail.FileSize)
suite.Equal("LIQJfl_3IU?b~qM{ofayWBWVofRj", attachment.Blurhash) suite.Equal("LIQ9}}_3IU?b~qM{ofayWBWVofRj", 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)
@ -548,8 +613,8 @@ func (suite *ManagerTestSuite) TestBirdnestMp4Process() {
suite.Equal("video/mp4", attachment.File.ContentType) suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/webp", attachment.Thumbnail.ContentType) suite.Equal("image/webp", attachment.Thumbnail.ContentType)
suite.Equal(1409625, attachment.File.FileSize) suite.Equal(1409625, attachment.File.FileSize)
suite.Equal(14478, attachment.Thumbnail.FileSize) suite.Equal(15056, attachment.Thumbnail.FileSize)
suite.Equal("LLF$qyaeRO.9DgM_RPaetkV@WCMw", attachment.Blurhash) suite.Equal("LLF$nqafRO.9DgM_RPadtkV@WCMx", 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -227,6 +227,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...) columns = append(columns, []string{"description", "description_text"}...)
} }
// validate & update site custom css if it's set on the form
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.InstanceCustomCSS(customCSS); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
instance.CustomCSS = text.SanitizeToPlaintext(customCSS)
columns = append(columns, []string{"custom_css"}...)
}
// Validate & update site // Validate & update site
// terms if set on the form. // terms if set on the form.
if form.Terms != nil { if form.Terms != nil {

View file

@ -1523,6 +1523,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Title: i.Title, Title: i.Title,
Description: i.Description, Description: i.Description,
DescriptionText: i.DescriptionText, DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
ShortDescription: i.ShortDescription, ShortDescription: i.ShortDescription,
ShortDescriptionText: i.ShortDescriptionText, ShortDescriptionText: i.ShortDescriptionText,
Email: i.ContactEmail, Email: i.ContactEmail,
@ -1644,6 +1645,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
SourceURL: instanceSourceURL, SourceURL: instanceSourceURL,
Description: i.Description, Description: i.Description,
DescriptionText: i.DescriptionText, DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(), Languages: config.GetInstanceLanguages().TagStrs(),
Rules: c.InstanceRulesToAPIRules(i.Rules), Rules: c.InstanceRulesToAPIRules(i.Rules),

View file

@ -189,6 +189,16 @@ func CustomCSS(customCSS string) error {
return nil return nil
} }
func InstanceCustomCSS(customCSS string) error {
maximumCustomCSSLength := config.GetAccountsCustomCSSLength()
if length := len([]rune(customCSS)); length > maximumCustomCSSLength {
return fmt.Errorf("custom_css must be less than %d characters, but submitted custom_css was %d characters", maximumCustomCSSLength, length)
}
return nil
}
// EmojiShortcode just runs the given shortcode through the regular expression // EmojiShortcode just runs the given shortcode through the regular expression
// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters, // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters,
// a-zA-Z, numbers, and underscores. // a-zA-Z, numbers, and underscores.

View file

@ -54,7 +54,7 @@ func (m *Module) aboutGETHandler(c *gin.Context) {
Template: "about.tmpl", Template: "about.tmpl",
Instance: instance, Instance: instance,
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout}, Stylesheets: []string{cssAbout, instanceCustomCSSPath},
Extra: map[string]any{ Extra: map[string]any{
"showStrap": true, "showStrap": true,
"blocklistExposed": config.GetInstanceExposeSuspendedWeb(), "blocklistExposed": config.GetInstanceExposeSuspendedWeb(),

View file

@ -129,6 +129,7 @@ func (m *Module) confirmEmailPOSTHandler(c *gin.Context) {
page := apiutil.WebPage{ page := apiutil.WebPage{
Template: "confirmed_email.tmpl", Template: "confirmed_email.tmpl",
Instance: instance, Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
Extra: map[string]any{ Extra: map[string]any{
"email": user.Email, "email": user.Email,
"username": user.Account.Username, "username": user.Account.Username,

View file

@ -55,3 +55,22 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
c.Header(cacheControlHeader, cacheControlNoCache) c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS)) c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS))
} }
func (m *Module) instanceCustomCSSGETHandler(c *gin.Context) {
if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
instanceV1, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
instanceCustomCSS := instanceV1.CustomCSS
c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(instanceCustomCSS))
}

View file

@ -67,7 +67,7 @@ func (m *Module) domainBlockListGETHandler(c *gin.Context) {
Template: "domain-blocklist.tmpl", Template: "domain-blocklist.tmpl",
Instance: instance, Instance: instance,
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA}, Stylesheets: []string{cssFA, instanceCustomCSSPath},
Javascript: []string{jsFrontend}, Javascript: []string{jsFrontend},
Extra: map[string]any{"blocklist": domainBlocks}, Extra: map[string]any{"blocklist": domainBlocks},
} }

View file

@ -59,7 +59,7 @@ func (m *Module) indexHandler(c *gin.Context) {
Template: "index.tmpl", Template: "index.tmpl",
Instance: instance, Instance: instance,
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout, cssIndex}, Stylesheets: []string{cssAbout, cssIndex, instanceCustomCSSPath},
Extra: map[string]any{"showStrap": true}, Extra: map[string]any{"showStrap": true},
} }

View file

@ -132,7 +132,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
} }
// Prepare stylesheets for profile. // Prepare stylesheets for profile.
stylesheets := make([]string, 0, 6) stylesheets := make([]string, 0, 7)
// Basic profile stylesheets. // Basic profile stylesheets.
stylesheets = append( stylesheets = append(
@ -142,6 +142,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
cssStatus, cssStatus,
cssThread, cssThread,
cssProfile, cssProfile,
instanceCustomCSSPath,
}..., }...,
) )

View file

@ -53,6 +53,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) {
cssProfile, // Used for rendering stub/fake profiles. cssProfile, // Used for rendering stub/fake profiles.
cssStatus, // Used for rendering stub/fake statuses. cssStatus, // Used for rendering stub/fake statuses.
cssSettings, cssSettings,
instanceCustomCSSPath,
}, },
Javascript: []string{jsSettings}, Javascript: []string{jsSettings},
} }

View file

@ -128,6 +128,7 @@ func (m *Module) signupPOSTHandler(c *gin.Context) {
page := apiutil.WebPage{ page := apiutil.WebPage{
Template: "signed-up.tmpl", Template: "signed-up.tmpl",
Instance: instance, Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{ Extra: map[string]any{
"email": user.UnconfirmedEmail, "email": user.UnconfirmedEmail,

View file

@ -59,7 +59,7 @@ func (m *Module) tagGETHandler(c *gin.Context) {
Template: "tag.tmpl", Template: "tag.tmpl",
Instance: instance, Instance: instance,
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA, cssThread, cssTag}, Stylesheets: []string{cssFA, cssThread, cssTag, instanceCustomCSSPath},
Extra: map[string]any{"tagName": tagName}, Extra: map[string]any{"tagName": tagName},
} }

View file

@ -115,7 +115,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
} }
// Prepare stylesheets for thread. // Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5) stylesheets := make([]string, 0, 6)
// Basic thread stylesheets. // Basic thread stylesheets.
stylesheets = append( stylesheets = append(
@ -131,6 +131,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
if theme := targetAccount.Theme; theme != "" { if theme := targetAccount.Theme; theme != "" {
stylesheets = append( stylesheets = append(
stylesheets, stylesheets,
instanceCustomCSSPath,
themesPathPrefix+"/"+theme, themesPathPrefix+"/"+theme,
) )
} }

View file

@ -41,6 +41,7 @@
statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group
tagsPath = "/tags/:" + apiutil.TagNameKey tagsPath = "/tags/:" + apiutil.TagNameKey
customCSSPath = profileGroupPath + "/custom.css" customCSSPath = profileGroupPath + "/custom.css"
instanceCustomCSSPath = "/custom.css"
rssFeedPath = profileGroupPath + "/feed.rss" rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets" assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist" distPathPrefix = assetsPathPrefix + "/dist"
@ -114,6 +115,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)
r.AttachHandler(http.MethodGet, instanceCustomCSSPath, m.instanceCustomCSSGETHandler)
r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler) r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler)
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler) r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler)

View file

@ -25,6 +25,7 @@ export interface InstanceV1 {
description_text?: string; description_text?: string;
short_description: string; short_description: string;
short_description_text?: string; short_description_text?: string;
custom_css: string;
email: string; email: string;
version: string; version: string;
debug?: boolean; debug?: boolean;

View file

@ -66,6 +66,10 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
valueSelector: (s: InstanceV1) => s.description_text, valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less` validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}), }),
customCSS: useTextInput("custom_css", {
source: instance,
valueSelector: (s: InstanceV1) => s.custom_css
}),
terms: useTextInput("terms", { terms: useTextInput("terms", {
source: instance, source: instance,
// Select "raw" text version of parsed field for editing. // Select "raw" text version of parsed field for editing.
@ -191,6 +195,15 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
type="email" type="email"
/> />
<TextArea
field={form.customCSS}
label={"Custom CSS"}
className="monospace"
rows={8}
autoCapitalize="none"
spellCheck="false"
/>
<MutationButton label="Save" result={result} disabled={false} /> <MutationButton label="Save" result={result} disabled={false} />
</form> </form>
); );