diff options
author | 2024-09-09 18:07:25 +0200 | |
---|---|---|
committer | 2024-09-09 18:07:25 +0200 | |
commit | 5543fd53400037dc8ae22d4919b7085c46177ce1 (patch) | |
tree | 9f4b9df4a65165e32888ac038a7c5f6f0724233d /internal | |
parent | [chore]: Bump golang.org/x/crypto from 0.26.0 to 0.27.0 (#3283) (diff) | |
download | gotosocial-5543fd53400037dc8ae22d4919b7085c46177ce1.tar.xz |
[feature/frontend] Add options to include Unlisted posts or hide all posts (#3272)
* [feature/frontend] Add options to include Unlisted posts or hide all posts
* finish up
* swagger
* move invalidate call into bundb package, avoid invalidating if not necessary
* rename show_web_statuses => web_visibility
* don't use ptr for webvisibility
* last bits
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/accounts/accountupdate.go | 12 | ||||
-rw-r--r-- | internal/api/model/account.go | 3 | ||||
-rw-r--r-- | internal/api/model/source.go | 5 | ||||
-rw-r--r-- | internal/api/model/status.go | 2 | ||||
-rw-r--r-- | internal/db/account.go | 7 | ||||
-rw-r--r-- | internal/db/bundb/account.go | 67 | ||||
-rw-r--r-- | internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go | 69 | ||||
-rw-r--r-- | internal/filter/visibility/account.go | 2 | ||||
-rw-r--r-- | internal/filter/visibility/filter.go | 4 | ||||
-rw-r--r-- | internal/filter/visibility/home_timeline.go | 2 | ||||
-rw-r--r-- | internal/filter/visibility/public_timeline.go | 2 | ||||
-rw-r--r-- | internal/filter/visibility/status.go | 64 | ||||
-rw-r--r-- | internal/gtsmodel/accountsettings.go | 5 | ||||
-rw-r--r-- | internal/gtsmodel/status.go | 3 | ||||
-rw-r--r-- | internal/processing/account/rss.go | 2 | ||||
-rw-r--r-- | internal/processing/account/statuses.go | 14 | ||||
-rw-r--r-- | internal/processing/account/update.go | 356 | ||||
-rw-r--r-- | internal/typeutils/frontendtointernal.go | 2 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 1 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 2 |
20 files changed, 467 insertions, 157 deletions
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 @@ import ( // 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 <http://www.gnu.org/licenses/>. + +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 @@ import ( "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) - ) - - for _, updateField := range fieldsAttributes { - if updateField.Name == nil || updateField.Value == nil { - continue - } - - var ( - name string = *updateField.Name - value string = *updateField.Value - ) + // Field text is changing. + textChanged = true - 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 nil, gtserror.NewErrorBadRequest(err, err.Error()) + if err := p.updateFields( + account, + *form.FieldsAttributes, + ); err != nil { + return nil, err } - - // 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 <p> 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 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.UpdateAccount(ctx, account); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %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 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)) + 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 <p> 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", |