[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
This commit is contained in:
tobi 2024-09-09 18:07:25 +02:00 committed by GitHub
commit 5543fd5340
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 523 additions and 161 deletions

View file

@ -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")
}

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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 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 := &gtsmodel.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 := &gtsmodel.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

View file

@ -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 ""
}

View file

@ -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,

View file

@ -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",