diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 8d11ba7da..07c939188 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -157,6 +157,14 @@ definitions: description: The default posting content type for new statuses. type: string x-go-name: StatusContentType + web_visibility: + description: |- + Visibility level(s) of posts to show for this account via the web api. + "public" = default, show only Public visibility posts on the web. + "unlisted" = show Public *and* Unlisted visibility posts on the web. + "none" = show no posts on the web, not even Public ones. + type: string + x-go-name: WebVisibility title: Source represents display or publishing preferences of user's own account. type: object x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model @@ -4400,6 +4408,14 @@ paths: in: formData name: hide_collections type: boolean + - description: |- + Posts to show on the web view of the account. + "public": default, show only Public visibility posts on the web. + "unlisted": show Public *and* Unlisted visibility posts on the web. + "none": show no posts on the web, not even Public ones. + in: formData + name: web_visibility + type: string - description: Name of 1st profile field to be added to this account's profile. (The index may be any string; add more indexes to send more fields.) in: formData name: fields_attributes[0][name] diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index ed069c19c..66452578d 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -76,6 +76,26 @@ Some examples: ### Visibility and Privacy +#### Visibility Level of Posts to Show on Your Profile + +Using this dropdown, you can choose what visibility level(s) of posts should be shown on the public web view of your profile, and served in your RSS feed (if you have enabled RSS). + +**By default, GoToSocial shows only Public visibility posts on the web view of your profile, not Unlisted.** You can adjust this setting to also show Unlisted visibility posts on your profile, which is similar to the default for other ActivityPub softwares like Mastodon etc. + +You can also choose to show no posts at all on the web view of your profile. This allows you to write posts without having to worry about scrapers, rubberneckers, and other nosy parkers visiting your web profile and looking at your posts. + +This setting does not affect visibility of your posts over the ActivityPub protocol, so even if you choose to show no posts on your public web profile, others will be able to see your posts in their client if they follow you, and/or have your posts boosted onto their timeline, use a link to search a post of yours, etc. + +!!! warning + Be aware that changes to this setting also apply retroactively. + + That is, if you previously made a post on Unlisted visibility, while set to show only Public posts on your profile, and you change this setting to show Public and Unlisted, then the Unlisted post you previously made will be visible on your profile alongside your Public posts. + + Likewise, if you change this setting to show no posts, then all your posts will be hidden from your profile, regardless of when you created them, and what this option was set to at the time. This will apply until you change this setting again. + +!!! tip + Alongside (domain-)blocking, this is a good "emergency" setting to use if you're facing harassment from people trawling through your public posts. It won't hide your posts from people who can see them in their clients, via ActivityPub, but it will at least prevent them from being able to click through your posts in their browser with no authentication, and easily share them with others with a URL. + #### Manually Approve Follow Requests (aka Lock Your Account) This checkbox allows you to decide whether or not you want to manually review follow requests to your account. diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go index f81f54db0..5d3a3da5f 100644 --- a/internal/api/client/accounts/accountupdate.go +++ b/internal/api/client/accounts/accountupdate.go @@ -145,6 +145,15 @@ // description: Hide the account's following/followers collections. // type: boolean // - +// name: web_visibility +// in: formData +// description: |- +// Posts to show on the web view of the account. +// "public": default, show only Public visibility posts on the web. +// "unlisted": show Public *and* Unlisted visibility posts on the web. +// "none": show no posts on the web, not even Public ones. +// type: string +// - // name: fields_attributes[0][name] // in: formData // description: Name of 1st profile field to be added to this account's profile. @@ -339,7 +348,8 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, form.Theme == nil && form.CustomCSS == nil && form.EnableRSS == nil && - form.HideCollections == nil) { + form.HideCollections == nil && + form.WebVisibility == nil) { return nil, errors.New("empty form submitted") } diff --git a/internal/api/model/account.go b/internal/api/model/account.go index 0eaf52734..d34d7d519 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -227,6 +227,9 @@ type UpdateCredentialsRequest struct { EnableRSS *bool `form:"enable_rss" json:"enable_rss"` // Hide this account's following/followers collections. HideCollections *bool `form:"hide_collections" json:"hide_collections"` + // Visibility of statuses to show via the web view. + // "none", "public" (default), or "unlisted" (which includes public as well). + WebVisibility *string `form:"web_visibility" json:"web_visibility"` } // UpdateSource is to be used specifically in an UpdateCredentialsRequest. diff --git a/internal/api/model/source.go b/internal/api/model/source.go index 3b57f8565..cc3eb78ee 100644 --- a/internal/api/model/source.go +++ b/internal/api/model/source.go @@ -26,6 +26,11 @@ type Source struct { // private = Followers-only post // direct = Direct post Privacy Visibility `json:"privacy"` + // Visibility level(s) of posts to show for this account via the web api. + // "public" = default, show only Public visibility posts on the web. + // "unlisted" = show Public *and* Unlisted visibility posts on the web. + // "none" = show no posts on the web, not even Public ones. + WebVisibility Visibility `json:"web_visibility"` // Whether new statuses should be marked sensitive by default. Sensitive bool `json:"sensitive"` // The default posting language for new statuses. diff --git a/internal/api/model/status.go b/internal/api/model/status.go index d0acafae8..9b83fa582 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -232,6 +232,8 @@ type StatusCreateRequest struct { type Visibility string const ( + // VisibilityNone is visible to nobody. This is only used for the visibility of web statuses. + VisibilityNone Visibility = "none" // VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users. VisibilityPublic Visibility = "public" // VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc. diff --git a/internal/db/account.go b/internal/db/account.go index 45a4ccc09..225c8e1d2 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -117,12 +117,11 @@ type Account interface { // In the case of no statuses, this function will return db.ErrNoEntries. GetAccountPinnedStatuses(ctx context.Context, accountID string) ([]*gtsmodel.Status, error) - // GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for returning statuses that - // should be visible via the web view of an account. So, only public, federated statuses that aren't boosts - // or replies. + // GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for + // returning statuses that should be visible via the web view of a *LOCAL* account. // // In the case of no statuses, this function will return db.ErrNoEntries. - GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, error) + GetAccountWebStatuses(ctx context.Context, account *gtsmodel.Account, limit int, maxID string) ([]*gtsmodel.Status, error) // SetAccountHeaderOrAvatar sets the header or avatar for the given accountID to the given media attachment. SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) error diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index d8ec26291..1569af9cb 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -1047,7 +1047,18 @@ func (a *accountDB) GetAccountPinnedStatuses(ctx context.Context, accountID stri return a.state.DB.GetStatusesByIDs(ctx, statusIDs) } -func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, error) { +func (a *accountDB) GetAccountWebStatuses( + ctx context.Context, + account *gtsmodel.Account, + limit int, + maxID string, +) ([]*gtsmodel.Status, error) { + // Check for an easy case: account exposes no statuses via the web. + webVisibility := account.Settings.WebVisibility + if webVisibility == gtsmodel.VisibilityNone { + return nil, db.ErrNoEntries + } + // Ensure reasonable if limit < 0 { limit = 0 @@ -1061,14 +1072,36 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). // Select only IDs from table Column("status.id"). - Where("? = ?", bun.Ident("status.account_id"), accountID). + Where("? = ?", bun.Ident("status.account_id"), account.ID). // Don't show replies or boosts. Where("? IS NULL", bun.Ident("status.in_reply_to_uri")). - Where("? IS NULL", bun.Ident("status.boost_of_id")). + Where("? IS NULL", bun.Ident("status.boost_of_id")) + + // Select statuses for this account according + // to their web visibility preference. + switch webVisibility { + + case gtsmodel.VisibilityPublic: // Only Public statuses. - Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic). - // Don't show local-only statuses on the web view. - Where("? = ?", bun.Ident("status.federated"), true) + q = q.Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic) + + case gtsmodel.VisibilityUnlocked: + // Public or Unlocked. + visis := []gtsmodel.Visibility{ + gtsmodel.VisibilityPublic, + gtsmodel.VisibilityUnlocked, + } + q = q.Where("? IN (?)", bun.Ident("status.visibility"), bun.In(visis)) + + default: + return nil, gtserror.Newf( + "unrecognized web visibility for account %s: %s", + account.ID, webVisibility, + ) + } + + // Don't show local-only statuses on the web view. + q = q.Where("? = ?", bun.Ident("status.federated"), true) // return only statuses LOWER (ie., older) than maxID if maxID == "" { @@ -1145,10 +1178,30 @@ func (a *accountDB) UpdateAccountSettings( ) error { return a.state.Caches.DB.AccountSettings.Store(settings, func() error { settings.UpdatedAt = time.Now() - if len(columns) > 0 { + + switch { + + case len(columns) != 0: // If we're updating by column, // ensure "updated_at" is included. columns = append(columns, "updated_at") + + // If we're updating web_visibility we should + // fall through + invalidate visibility cache. + if !slices.Contains(columns, "web_visibility") { + break // No need to invalidate. + } + + // Fallthrough + // to invalidate. + fallthrough + + case len(columns) == 0: + // Status visibility may be changing for this account. + // Clear the visibility cache for unauthed requesters. + // + // todo: invalidate JUST this account's statuses. + defer a.state.Caches.Visibility.Clear() } if _, err := a.db. diff --git a/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go b/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go new file mode 100644 index 000000000..473783790 --- /dev/null +++ b/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go @@ -0,0 +1,69 @@ +// 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 . + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + + // If column already exists we don't need to do anything. + exists, err := doesColumnExist(ctx, tx, + "account_settings", "web_visibility", + ) + + if err != nil { + // Real error. + return err + } else if exists { + // Nothing to do. + return nil + } + + // Create the new column. + if _, err := tx.NewAddColumn(). + Table("account_settings"). + ColumnExpr( + "? TEXT NOT NULL DEFAULT ?", + bun.Ident("web_visibility"), + gtsmodel.VisibilityPublic, + ). + 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/filter/visibility/account.go b/internal/filter/visibility/account.go index 410daa1ce..ebbbe4a2f 100644 --- a/internal/filter/visibility/account.go +++ b/internal/filter/visibility/account.go @@ -32,7 +32,7 @@ func (f *Filter) AccountVisible(ctx context.Context, requester *gtsmodel.Account const vtype = cache.VisibilityTypeAccount // By default we assume no auth. - requesterID := noauth + requesterID := NoAuth if requester != nil { // Use provided account ID. diff --git a/internal/filter/visibility/filter.go b/internal/filter/visibility/filter.go index c9f007ccf..43f862681 100644 --- a/internal/filter/visibility/filter.go +++ b/internal/filter/visibility/filter.go @@ -21,9 +21,9 @@ "github.com/superseriousbusiness/gotosocial/internal/state" ) -// noauth is a placeholder ID used in cache lookups +// NoAuth is a placeholder ID used in cache lookups // when there is no authorized account ID to use. -const noauth = "noauth" +const NoAuth = "noauth" // Filter packages up a bunch of logic for checking whether // given statuses or accounts are visible to a requester. diff --git a/internal/filter/visibility/home_timeline.go b/internal/filter/visibility/home_timeline.go index af583a847..9c224ffbb 100644 --- a/internal/filter/visibility/home_timeline.go +++ b/internal/filter/visibility/home_timeline.go @@ -35,7 +35,7 @@ func (f *Filter) StatusHomeTimelineable(ctx context.Context, owner *gtsmodel.Acc const vtype = cache.VisibilityTypeHome // By default we assume no auth. - requesterID := noauth + requesterID := NoAuth if owner != nil { // Use provided account ID. diff --git a/internal/filter/visibility/public_timeline.go b/internal/filter/visibility/public_timeline.go index bad7cf991..9cc3c2357 100644 --- a/internal/filter/visibility/public_timeline.go +++ b/internal/filter/visibility/public_timeline.go @@ -33,7 +33,7 @@ func (f *Filter) StatusPublicTimelineable(ctx context.Context, requester *gtsmod const vtype = cache.VisibilityTypePublic // By default we assume no auth. - requesterID := noauth + requesterID := NoAuth if requester != nil { // Use provided account ID. diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go index fdeefedde..be59e800e 100644 --- a/internal/filter/visibility/status.go +++ b/internal/filter/visibility/status.go @@ -54,7 +54,7 @@ func (f *Filter) StatusVisible( const vtype = cache.VisibilityTypeStatus // By default we assume no auth. - requesterID := noauth + requesterID := NoAuth if requester != nil { // Use provided account ID. @@ -113,9 +113,9 @@ func (f *Filter) isStatusVisible( } if requester == nil { - // The request is unauthed. Only federated, Public statuses are visible without auth. - visibleUnauthed := !status.IsLocalOnly() && status.Visibility == gtsmodel.VisibilityPublic - return visibleUnauthed, nil + // Use a different visibility + // heuristic for unauthed requests. + return f.isStatusVisibleUnauthed(ctx, status) } /* @@ -245,6 +245,62 @@ func (f *Filter) isPendingStatusVisible( return false, nil } +func (f *Filter) isStatusVisibleUnauthed( + ctx context.Context, + status *gtsmodel.Status, +) (bool, error) { + // For remote accounts, only show + // Public statuses via the web. + if status.Account.IsRemote() { + return status.Visibility == gtsmodel.VisibilityPublic, nil + } + + // If status is local only, + // never show via the web. + if status.IsLocalOnly() { + return false, nil + } + + // Check account's settings to see + // what they expose. Populate these + // from the DB if necessary. + if status.Account.Settings == nil { + var err error + status.Account.Settings, err = f.state.DB.GetAccountSettings(ctx, status.Account.ID) + if err != nil { + return false, gtserror.Newf( + "error getting settings for account %s: %w", + status.Account.ID, err, + ) + } + } + + webVisibility := status.Account.Settings.WebVisibility + switch webVisibility { + + // public_only: status must be Public. + case gtsmodel.VisibilityPublic: + return status.Visibility == gtsmodel.VisibilityPublic, nil + + // unlisted: status must be Public or Unlocked. + case gtsmodel.VisibilityUnlocked: + visible := status.Visibility == gtsmodel.VisibilityPublic || + status.Visibility == gtsmodel.VisibilityUnlocked + return visible, nil + + // none: never show via the web. + case gtsmodel.VisibilityNone: + return false, nil + + // Huh? + default: + return false, gtserror.Newf( + "unrecognized web visibility for account %s: %s", + status.Account.ID, webVisibility, + ) + } +} + // areStatusAccountsVisible calls Filter{}.AccountVisible() on status author and the status boost-of (if set) author, returning visibility of status (and boost-of) to requester. func (f *Filter) areStatusAccountsVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { // Check whether status author's account is visible to requester. diff --git a/internal/gtsmodel/accountsettings.go b/internal/gtsmodel/accountsettings.go index 592a2330d..3151ba5b7 100644 --- a/internal/gtsmodel/accountsettings.go +++ b/internal/gtsmodel/accountsettings.go @@ -17,7 +17,9 @@ package gtsmodel -import "time" +import ( + "time" +) // AccountSettings models settings / preferences for a local, non-instance account. type AccountSettings struct { @@ -32,6 +34,7 @@ type AccountSettings struct { CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses. EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections. + WebVisibility Visibility `bun:",nullzero,notnull,default:public"` // Visibility level of statuses that visitors can view via the web profile. InteractionPolicyDirect *InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy. InteractionPolicyMutualsOnly *InteractionPolicy `bun:""` // Interaction policy to use for new mutuals only visibility statuses. If null, assume default policy. InteractionPolicyFollowersOnly *InteractionPolicy `bun:""` // Interaction policy to use for new followers only visibility statuses. If null, assume default policy. diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index 70fd9c367..9ebbc18c7 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -238,6 +238,9 @@ type StatusToEmoji struct { type Visibility string const ( + // VisibilityNone means nobody can see this. + // It's only used for web status visibility. + VisibilityNone Visibility = "none" // VisibilityPublic means this status will be visible to everyone on all timelines. VisibilityPublic Visibility = "public" // VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists. diff --git a/internal/processing/account/rss.go b/internal/processing/account/rss.go index 60f93b012..22ba0fe42 100644 --- a/internal/processing/account/rss.go +++ b/internal/processing/account/rss.go @@ -116,7 +116,7 @@ func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) feed.Updated = lastPostAt // Retrieve latest statuses as they'd be shown on the web view of the account profile. - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account.ID, rssFeedLength, "") + statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, rssFeedLength, "") if err != nil && !errors.Is(err, db.ErrNoEntries) { err = fmt.Errorf("db error getting account web statuses: %w", err) return "", gtserror.NewErrorInternalError(err) diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 2bab812e3..8029a460b 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -159,7 +159,7 @@ func (p *Processor) WebStatusesGet( return nil, gtserror.NewErrorNotFound(err) } - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID) + statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, 10, maxID) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorInternalError(err) } @@ -206,9 +206,15 @@ func (p *Processor) WebStatusesGetPinned( webStatuses := make([]*apimodel.WebStatus, 0, len(statuses)) for _, status := range statuses { - if status.Visibility != gtsmodel.VisibilityPublic { - // Skip non-public - // pinned status. + // Ensure visible via the web. + visible, err := p.visFilter.StatusVisible(ctx, nil, status) + if err != nil { + log.Errorf(ctx, "error checking status visibility: %v", err) + continue + } + + if !visible { + // Don't serve. continue } diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index fda871bd5..58e52a992 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -54,21 +54,44 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form log.Errorf(ctx, "error(s) populating account, will continue: %s", err) } + var ( + // Indicates that the account's + // note, display name, and/or fields + // have changed, and so emojis should + // be re-parsed and updated as well. + textChanged bool + + // DB columns on the account + // that need to be updated. + acctColumns []string + + // DB columns on the settings + // that need to be updated. + settingsColumns []string + ) + + // Account flags. + if form.Discoverable != nil { account.Discoverable = form.Discoverable + acctColumns = append(acctColumns, "discoverable") } if form.Bot != nil { account.Bot = form.Bot + acctColumns = append(acctColumns, "bot") } - // Via the process of updating the account, - // it is possible that the emojis used by - // that account in note/display name/fields - // may change; we need to keep track of this. - var emojisChanged bool + if form.Locked != nil { + account.Locked = form.Locked + acctColumns = append(acctColumns, "locked") + } if form.DisplayName != nil { + // Display name text + // is changing. + textChanged = true + displayName := *form.DisplayName if err := validate.DisplayName(displayName); err != nil { return nil, gtserror.NewErrorBadRequest(err, err.Error()) @@ -76,137 +99,54 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form // Parse new display name (always from plaintext). account.DisplayName = text.SanitizeToPlaintext(displayName) - - // If display name has changed, account emojis may have also changed. - emojisChanged = true + acctColumns = append(acctColumns, "display_name") } if form.Note != nil { + // Note text is changing. + textChanged = true + note := *form.Note if err := validate.Note(note); err != nil { return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - // Store raw version of the note for now, - // we'll process the proper version later. + // Store raw version of note + // for now, we'll process + // the proper version later. account.NoteRaw = note - - // If note has changed, account emojis may have also changed. - emojisChanged = true + acctColumns = append(acctColumns, []string{ + "note", + "note_raw", + }...) } if form.FieldsAttributes != nil { - var ( - fieldsAttributes = *form.FieldsAttributes - fieldsLen = len(fieldsAttributes) - fieldsRaw = make([]*gtsmodel.Field, 0, fieldsLen) - ) + // Field text is changing. + textChanged = true - for _, updateField := range fieldsAttributes { - if updateField.Name == nil || updateField.Value == nil { - continue - } - - var ( - name string = *updateField.Name - value string = *updateField.Value - ) - - if name == "" || value == "" { - continue - } - - // Sanitize raw field values. - fieldRaw := >smodel.Field{ - Name: text.SanitizeToPlaintext(name), - Value: text.SanitizeToPlaintext(value), - } - fieldsRaw = append(fieldsRaw, fieldRaw) + if err := p.updateFields( + account, + *form.FieldsAttributes, + ); err != nil { + return nil, err } - - // Check length of parsed raw fields. - if err := validate.ProfileFields(fieldsRaw); err != nil { - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // OK, new raw fields are valid. - account.FieldsRaw = fieldsRaw - account.Fields = make([]*gtsmodel.Field, 0, fieldsLen) // process these in a sec - - // If fields have changed, account emojis may also have changed. - emojisChanged = true + acctColumns = append(acctColumns, []string{ + "fields", + "fields_raw", + }...) } - if emojisChanged { - // Use map to deduplicate emojis by their ID. - emojis := make(map[string]*gtsmodel.Emoji) - - // Retrieve display name emojis. - for _, emoji := range p.formatter.FromPlainEmojiOnly( - ctx, - p.parseMention, - account.ID, - "", - account.DisplayName, - ).Emojis { - emojis[emoji.ID] = emoji - } - - // Format + set note according to user prefs. - f := p.selectNoteFormatter(account.Settings.StatusContentType) - formatNoteResult := f(ctx, p.parseMention, account.ID, "", account.NoteRaw) - account.Note = formatNoteResult.HTML - - // Retrieve note emojis. - for _, emoji := range formatNoteResult.Emojis { - emojis[emoji.ID] = emoji - } - - // Process the raw fields we stored earlier. - account.Fields = make([]*gtsmodel.Field, 0, len(account.FieldsRaw)) - for _, fieldRaw := range account.FieldsRaw { - field := >smodel.Field{} - - // Name stays plain, but we still need to - // see if there are any emojis set in it. - field.Name = fieldRaw.Name - for _, emoji := range p.formatter.FromPlainEmojiOnly( - ctx, - p.parseMention, - account.ID, - "", - fieldRaw.Name, - ).Emojis { - emojis[emoji.ID] = emoji - } - - // Value can be HTML, but we don't want - // to wrap the result in

tags. - fieldFormatValueResult := p.formatter.FromPlainNoParagraph(ctx, p.parseMention, account.ID, "", fieldRaw.Value) - field.Value = fieldFormatValueResult.HTML - - // Retrieve field emojis. - for _, emoji := range fieldFormatValueResult.Emojis { - emojis[emoji.ID] = emoji - } - - // We're done, append the shiny new field. - account.Fields = append(account.Fields, field) - } - - emojisCount := len(emojis) - account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount) - account.EmojiIDs = make([]string, 0, emojisCount) - - for id, emoji := range emojis { - account.Emojis = append(account.Emojis, emoji) - account.EmojiIDs = append(account.EmojiIDs, id) - } + if textChanged { + // Process display name, note, fields, + // and any concomitant emoji changes. + p.processAccountText(ctx, account) + acctColumns = append(acctColumns, "emojis") } if form.AvatarDescription != nil { desc := text.SanitizeToPlaintext(*form.AvatarDescription) - form.AvatarDescription = util.Ptr(desc) + form.AvatarDescription = &desc } if form.Avatar != nil && form.Avatar.Size != 0 { @@ -220,7 +160,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo - log.Tracef(ctx, "new avatar info for account %s is %+v", account.ID, avatarInfo) + acctColumns = append(acctColumns, "avatar_media_attachment_id") } else if form.AvatarDescription != nil && account.AvatarMediaAttachment != nil { // Update just existing description if possible. account.AvatarMediaAttachment.Description = *form.AvatarDescription @@ -250,7 +190,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo - log.Tracef(ctx, "new header info for account %s is %+v", account.ID, headerInfo) + acctColumns = append(acctColumns, "header_media_attachment_id") } else if form.HeaderDescription != nil && account.HeaderMediaAttachment != nil { // Update just existing description if possible. account.HeaderMediaAttachment.Description = *form.HeaderDescription @@ -264,29 +204,32 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } } - if form.Locked != nil { - account.Locked = form.Locked - } + // Account settings flags. if form.Source != nil { if form.Source.Language != nil { language, err := validate.Language(*form.Source.Language) if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } + account.Settings.Language = language + settingsColumns = append(settingsColumns, "language") } if form.Source.Sensitive != nil { account.Settings.Sensitive = form.Source.Sensitive + settingsColumns = append(settingsColumns, "sensitive") } if form.Source.Privacy != nil { if err := validate.Privacy(*form.Source.Privacy); err != nil { - return nil, gtserror.NewErrorBadRequest(err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - privacy := typeutils.APIVisToVis(apimodel.Visibility(*form.Source.Privacy)) - account.Settings.Privacy = privacy + + priv := apimodel.Visibility(*form.Source.Privacy) + account.Settings.Privacy = typeutils.APIVisToVis(priv) + settingsColumns = append(settingsColumns, "privacy") } if form.Source.StatusContentType != nil { @@ -295,6 +238,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.Settings.StatusContentType = *form.Source.StatusContentType + settingsColumns = append(settingsColumns, "status_content_type") } } @@ -312,6 +256,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.Settings.Theme = theme } + settingsColumns = append(settingsColumns, "theme") } if form.CustomCSS != nil { @@ -319,25 +264,54 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form if err := validate.CustomCSS(customCSS); err != nil { return nil, gtserror.NewErrorBadRequest(err, err.Error()) } + account.Settings.CustomCSS = text.SanitizeToPlaintext(customCSS) + settingsColumns = append(settingsColumns, "custom_css") } if form.EnableRSS != nil { account.Settings.EnableRSS = form.EnableRSS + settingsColumns = append(settingsColumns, "enable_rss") } if form.HideCollections != nil { account.Settings.HideCollections = form.HideCollections + settingsColumns = append(settingsColumns, "hide_collections") } - if err := p.state.DB.UpdateAccount(ctx, account); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %s: %s", account.ID, err)) + if form.WebVisibility != nil { + apiVis := apimodel.Visibility(*form.WebVisibility) + webVisibility := typeutils.APIVisToVis(apiVis) + if webVisibility != gtsmodel.VisibilityPublic && + webVisibility != gtsmodel.VisibilityUnlocked && + webVisibility != gtsmodel.VisibilityNone { + const text = "web_visibility must be one of public, unlocked, or none" + err := errors.New(text) + return nil, gtserror.NewErrorBadRequest(err, text) + } + + account.Settings.WebVisibility = webVisibility + settingsColumns = append(settingsColumns, "web_visibility") } - if err := p.state.DB.UpdateAccountSettings(ctx, account.Settings); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account settings %s: %s", account.ID, err)) + // We've parsed + set everything, do + // necessary database updates now. + + if len(acctColumns) > 0 { + if err := p.state.DB.UpdateAccount(ctx, account, acctColumns...); err != nil { + err := gtserror.Newf("db error updating account %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } } + if len(settingsColumns) > 0 { + if err := p.state.DB.UpdateAccountSettings(ctx, account.Settings, settingsColumns...); err != nil { + err := gtserror.Newf("db error updating account settings %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // Send out Update message over the s2s (fedi) API. p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ APObjectType: ap.ActorPerson, APActivityType: ap.ActivityUpdate, @@ -347,11 +321,133 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form acctSensitive, err := p.converter.AccountToAPIAccountSensitive(ctx, account) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not convert account into apisensitive account: %s", err)) + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } + return acctSensitive, nil } +// updateFields sets FieldsRaw on the given +// account, and resets account.Fields to an +// empty slice, ready for further processing. +func (p *Processor) updateFields( + account *gtsmodel.Account, + fieldsAttributes []apimodel.UpdateField, +) gtserror.WithCode { + var ( + fieldsLen = len(fieldsAttributes) + fieldsRaw = make([]*gtsmodel.Field, 0, fieldsLen) + ) + + for _, updateField := range fieldsAttributes { + if updateField.Name == nil || updateField.Value == nil { + continue + } + + var ( + name string = *updateField.Name + value string = *updateField.Value + ) + + if name == "" || value == "" { + continue + } + + // Sanitize raw field values. + fieldRaw := >smodel.Field{ + Name: text.SanitizeToPlaintext(name), + Value: text.SanitizeToPlaintext(value), + } + fieldsRaw = append(fieldsRaw, fieldRaw) + } + + // Check length of parsed raw fields. + if err := validate.ProfileFields(fieldsRaw); err != nil { + return gtserror.NewErrorBadRequest(err, err.Error()) + } + + // OK, new raw fields are valid. + account.FieldsRaw = fieldsRaw + account.Fields = make([]*gtsmodel.Field, 0, fieldsLen) + return nil +} + +// processAccountText processes the raw versions of the given +// account's display name, note, and fields, and sets those +// processed versions on the account, while also updating the +// account's emojis entry based on the results of the processing. +func (p *Processor) processAccountText( + ctx context.Context, + account *gtsmodel.Account, +) { + // Use map to deduplicate emojis by their ID. + emojis := make(map[string]*gtsmodel.Emoji) + + // Retrieve display name emojis. + for _, emoji := range p.formatter.FromPlainEmojiOnly( + ctx, + p.parseMention, + account.ID, + "", + account.DisplayName, + ).Emojis { + emojis[emoji.ID] = emoji + } + + // Format + set note according to user prefs. + f := p.selectNoteFormatter(account.Settings.StatusContentType) + formatNoteResult := f(ctx, p.parseMention, account.ID, "", account.NoteRaw) + account.Note = formatNoteResult.HTML + + // Retrieve note emojis. + for _, emoji := range formatNoteResult.Emojis { + emojis[emoji.ID] = emoji + } + + // Process raw fields. + account.Fields = make([]*gtsmodel.Field, 0, len(account.FieldsRaw)) + for _, fieldRaw := range account.FieldsRaw { + field := >smodel.Field{} + + // Name stays plain, but we still need to + // see if there are any emojis set in it. + field.Name = fieldRaw.Name + for _, emoji := range p.formatter.FromPlainEmojiOnly( + ctx, + p.parseMention, + account.ID, + "", + fieldRaw.Name, + ).Emojis { + emojis[emoji.ID] = emoji + } + + // Value can be HTML, but we don't want + // to wrap the result in

tags. + fieldFormatValueResult := p.formatter.FromPlainNoParagraph(ctx, p.parseMention, account.ID, "", fieldRaw.Value) + field.Value = fieldFormatValueResult.HTML + + // Retrieve field emojis. + for _, emoji := range fieldFormatValueResult.Emojis { + emojis[emoji.ID] = emoji + } + + // We're done, append the shiny new field. + account.Fields = append(account.Fields, field) + } + + // Update the account's emojis. + emojisCount := len(emojis) + account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount) + account.EmojiIDs = make([]string, 0, emojisCount) + + for id, emoji := range emojis { + account.Emojis = append(account.Emojis, emoji) + account.EmojiIDs = append(account.EmojiIDs, id) + } +} + // UpdateAvatar does the dirty work of checking the avatar // part of an account update form, parsing and checking the // media, and doing the necessary updates in the database diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go index 8ced14d58..1f7d1877e 100644 --- a/internal/typeutils/frontendtointernal.go +++ b/internal/typeutils/frontendtointernal.go @@ -38,6 +38,8 @@ func APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility { return gtsmodel.VisibilityMutualsOnly case apimodel.VisibilityDirect: return gtsmodel.VisibilityDirect + case apimodel.VisibilityNone: + return gtsmodel.VisibilityNone } return "" } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 07a4c0836..5cbed62e0 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -134,6 +134,7 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode apiAccount.Source = &apimodel.Source{ Privacy: c.VisToAPIVis(ctx, a.Settings.Privacy), + WebVisibility: c.VisToAPIVis(ctx, a.Settings.WebVisibility), Sensitive: *a.Settings.Sensitive, Language: a.Settings.Language, StatusContentType: statusContentType, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 307b5f163..651ff867d 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -120,6 +120,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() "fields": [], "source": { "privacy": "public", + "web_visibility": "unlisted", "sensitive": false, "language": "en", "status_content_type": "text/plain", @@ -304,6 +305,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { "fields": [], "source": { "privacy": "public", + "web_visibility": "unlisted", "sensitive": false, "language": "en", "status_content_type": "text/plain", diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 26cc47f7d..171851d09 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -658,6 +658,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(false), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityPublic, }, "admin_account": { AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", @@ -668,6 +669,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityPublic, }, "local_account_1": { AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", @@ -678,6 +680,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityUnlocked, }, "local_account_2": { AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", @@ -688,6 +691,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "fr", EnableRSS: util.Ptr(false), HideCollections: util.Ptr(true), + WebVisibility: gtsmodel.VisibilityPublic, }, } } diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index 18c96e869..4e5fb627f 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -115,6 +115,7 @@ function UserProfileForm({ data: profile }) { discoverable: useBoolInput("discoverable", { source: profile}), enableRSS: useBoolInput("enable_rss", { source: profile }), hideCollections: useBoolInput("hide_collections", { source: profile }), + webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p) => p.source?.web_visibility }), fields: useFieldArrayInput("fields_attributes", { defaultValue: profile?.source?.fields, length: instanceConfig.maxPinnedFields @@ -233,21 +234,32 @@ function UserProfileForm({ data: profile }) { Learn more about these settings (opens in a new tab) +