[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
parent 7785fa54da
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

@ -157,6 +157,14 @@ definitions:
description: The default posting content type for new statuses. description: The default posting content type for new statuses.
type: string type: string
x-go-name: StatusContentType 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. title: Source represents display or publishing preferences of user's own account.
type: object type: object
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
@ -4400,6 +4408,14 @@ paths:
in: formData in: formData
name: hide_collections name: hide_collections
type: boolean 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.) - 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 in: formData
name: fields_attributes[0][name] name: fields_attributes[0][name]

View file

@ -76,6 +76,26 @@ Some examples:
### Visibility and Privacy ### 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) #### 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. This checkbox allows you to decide whether or not you want to manually review follow requests to your account.

View file

@ -145,6 +145,15 @@ import (
// description: Hide the account's following/followers collections. // description: Hide the account's following/followers collections.
// type: boolean // 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] // name: fields_attributes[0][name]
// in: formData // in: formData
// description: Name of 1st profile field to be added to this account's profile. // 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.Theme == nil &&
form.CustomCSS == nil && form.CustomCSS == nil &&
form.EnableRSS == nil && form.EnableRSS == nil &&
form.HideCollections == nil) { form.HideCollections == nil &&
form.WebVisibility == nil) {
return nil, errors.New("empty form submitted") return nil, errors.New("empty form submitted")
} }

View file

@ -227,6 +227,9 @@ type UpdateCredentialsRequest struct {
EnableRSS *bool `form:"enable_rss" json:"enable_rss"` EnableRSS *bool `form:"enable_rss" json:"enable_rss"`
// Hide this account's following/followers collections. // Hide this account's following/followers collections.
HideCollections *bool `form:"hide_collections" json:"hide_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. // UpdateSource is to be used specifically in an UpdateCredentialsRequest.

View file

@ -26,6 +26,11 @@ type Source struct {
// private = Followers-only post // private = Followers-only post
// direct = Direct post // direct = Direct post
Privacy Visibility `json:"privacy"` 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. // Whether new statuses should be marked sensitive by default.
Sensitive bool `json:"sensitive"` Sensitive bool `json:"sensitive"`
// The default posting language for new statuses. // The default posting language for new statuses.

View file

@ -232,6 +232,8 @@ type StatusCreateRequest struct {
type Visibility string type Visibility string
const ( 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 is visible to everyone, and will be available via the web even for nonauthenticated users.
VisibilityPublic Visibility = "public" VisibilityPublic Visibility = "public"
// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc. // 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. // In the case of no statuses, this function will return db.ErrNoEntries.
GetAccountPinnedStatuses(ctx context.Context, accountID string) ([]*gtsmodel.Status, error) GetAccountPinnedStatuses(ctx context.Context, accountID string) ([]*gtsmodel.Status, error)
// GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for returning statuses that // GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for
// should be visible via the web view of an account. So, only public, federated statuses that aren't boosts // returning statuses that should be visible via the web view of a *LOCAL* account.
// or replies.
// //
// In the case of no statuses, this function will return db.ErrNoEntries. // 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 sets the header or avatar for the given accountID to the given media attachment.
SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) error 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) 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 // Ensure reasonable
if limit < 0 { if limit < 0 {
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")). TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
// Select only IDs from table // Select only IDs from table
Column("status.id"). Column("status.id").
Where("? = ?", bun.Ident("status.account_id"), accountID). Where("? = ?", bun.Ident("status.account_id"), account.ID).
// Don't show replies or boosts. // Don't show replies or boosts.
Where("? IS NULL", bun.Ident("status.in_reply_to_uri")). 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. // Only Public statuses.
Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic). 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. // Don't show local-only statuses on the web view.
Where("? = ?", bun.Ident("status.federated"), true) q = q.Where("? = ?", bun.Ident("status.federated"), true)
// return only statuses LOWER (ie., older) than maxID // return only statuses LOWER (ie., older) than maxID
if maxID == "" { if maxID == "" {
@ -1145,10 +1178,30 @@ func (a *accountDB) UpdateAccountSettings(
) error { ) error {
return a.state.Caches.DB.AccountSettings.Store(settings, func() error { return a.state.Caches.DB.AccountSettings.Store(settings, func() error {
settings.UpdatedAt = time.Now() settings.UpdatedAt = time.Now()
if len(columns) > 0 {
switch {
case len(columns) != 0:
// If we're updating by column, // If we're updating by column,
// ensure "updated_at" is included. // ensure "updated_at" is included.
columns = append(columns, "updated_at") 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. 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 const vtype = cache.VisibilityTypeAccount
// By default we assume no auth. // By default we assume no auth.
requesterID := noauth requesterID := NoAuth
if requester != nil { if requester != nil {
// Use provided account ID. // Use provided account ID.

View file

@ -21,9 +21,9 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/state" "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. // 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 // Filter packages up a bunch of logic for checking whether
// given statuses or accounts are visible to a requester. // 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 const vtype = cache.VisibilityTypeHome
// By default we assume no auth. // By default we assume no auth.
requesterID := noauth requesterID := NoAuth
if owner != nil { if owner != nil {
// Use provided account ID. // Use provided account ID.

View file

@ -33,7 +33,7 @@ func (f *Filter) StatusPublicTimelineable(ctx context.Context, requester *gtsmod
const vtype = cache.VisibilityTypePublic const vtype = cache.VisibilityTypePublic
// By default we assume no auth. // By default we assume no auth.
requesterID := noauth requesterID := NoAuth
if requester != nil { if requester != nil {
// Use provided account ID. // Use provided account ID.

View file

@ -54,7 +54,7 @@ func (f *Filter) StatusVisible(
const vtype = cache.VisibilityTypeStatus const vtype = cache.VisibilityTypeStatus
// By default we assume no auth. // By default we assume no auth.
requesterID := noauth requesterID := NoAuth
if requester != nil { if requester != nil {
// Use provided account ID. // Use provided account ID.
@ -113,9 +113,9 @@ func (f *Filter) isStatusVisible(
} }
if requester == nil { if requester == nil {
// The request is unauthed. Only federated, Public statuses are visible without auth. // Use a different visibility
visibleUnauthed := !status.IsLocalOnly() && status.Visibility == gtsmodel.VisibilityPublic // heuristic for unauthed requests.
return visibleUnauthed, nil return f.isStatusVisibleUnauthed(ctx, status)
} }
/* /*
@ -245,6 +245,62 @@ func (f *Filter) isPendingStatusVisible(
return false, nil 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. // 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) { 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. // Check whether status author's account is visible to requester.

View file

@ -17,7 +17,9 @@
package gtsmodel package gtsmodel
import "time" import (
"time"
)
// AccountSettings models settings / preferences for a local, non-instance account. // AccountSettings models settings / preferences for a local, non-instance account.
type AccountSettings struct { 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. 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 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. 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. 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. 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. 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 type Visibility string
const ( 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 means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public" VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists. // 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 feed.Updated = lastPostAt
// Retrieve latest statuses as they'd be shown on the web view of the account profile. // 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) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("db error getting account web statuses: %w", err) err = fmt.Errorf("db error getting account web statuses: %w", err)
return "", gtserror.NewErrorInternalError(err) return "", gtserror.NewErrorInternalError(err)

View file

@ -159,7 +159,7 @@ func (p *Processor) WebStatusesGet(
return nil, gtserror.NewErrorNotFound(err) 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) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
@ -206,9 +206,15 @@ func (p *Processor) WebStatusesGetPinned(
webStatuses := make([]*apimodel.WebStatus, 0, len(statuses)) webStatuses := make([]*apimodel.WebStatus, 0, len(statuses))
for _, status := range statuses { for _, status := range statuses {
if status.Visibility != gtsmodel.VisibilityPublic { // Ensure visible via the web.
// Skip non-public visible, err := p.visFilter.StatusVisible(ctx, nil, status)
// pinned status. if err != nil {
log.Errorf(ctx, "error checking status visibility: %v", err)
continue
}
if !visible {
// Don't serve.
continue 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) 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 { if form.Discoverable != nil {
account.Discoverable = form.Discoverable account.Discoverable = form.Discoverable
acctColumns = append(acctColumns, "discoverable")
} }
if form.Bot != nil { if form.Bot != nil {
account.Bot = form.Bot account.Bot = form.Bot
acctColumns = append(acctColumns, "bot")
} }
// Via the process of updating the account, if form.Locked != nil {
// it is possible that the emojis used by account.Locked = form.Locked
// that account in note/display name/fields acctColumns = append(acctColumns, "locked")
// may change; we need to keep track of this. }
var emojisChanged bool
if form.DisplayName != nil { if form.DisplayName != nil {
// Display name text
// is changing.
textChanged = true
displayName := *form.DisplayName displayName := *form.DisplayName
if err := validate.DisplayName(displayName); err != nil { if err := validate.DisplayName(displayName); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
@ -76,28 +99,243 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
// Parse new display name (always from plaintext). // Parse new display name (always from plaintext).
account.DisplayName = text.SanitizeToPlaintext(displayName) account.DisplayName = text.SanitizeToPlaintext(displayName)
acctColumns = append(acctColumns, "display_name")
// If display name has changed, account emojis may have also changed.
emojisChanged = true
} }
if form.Note != nil { if form.Note != nil {
// Note text is changing.
textChanged = true
note := *form.Note note := *form.Note
if err := validate.Note(note); err != nil { if err := validate.Note(note); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
// Store raw version of the note for now, // Store raw version of note
// we'll process the proper version later. // for now, we'll process
// the proper version later.
account.NoteRaw = note account.NoteRaw = note
acctColumns = append(acctColumns, []string{
// If note has changed, account emojis may have also changed. "note",
emojisChanged = true "note_raw",
}...)
} }
if form.FieldsAttributes != nil { if form.FieldsAttributes != nil {
// Field text is changing.
textChanged = true
if err := p.updateFields(
account,
*form.FieldsAttributes,
); err != nil {
return nil, err
}
acctColumns = append(acctColumns, []string{
"fields",
"fields_raw",
}...)
}
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 = &desc
}
if form.Avatar != nil && form.Avatar.Size != 0 {
avatarInfo, errWithCode := p.UpdateAvatar(ctx,
account,
form.Avatar,
form.AvatarDescription,
)
if errWithCode != nil {
return nil, errWithCode
}
account.AvatarMediaAttachmentID = avatarInfo.ID
account.AvatarMediaAttachment = 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
if err := p.state.DB.UpdateAttachment(
ctx,
account.AvatarMediaAttachment,
"description",
); err != nil {
err := gtserror.Newf("db error updating account avatar description: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
if form.HeaderDescription != nil {
desc := text.SanitizeToPlaintext(*form.HeaderDescription)
form.HeaderDescription = util.Ptr(desc)
}
if form.Header != nil && form.Header.Size != 0 {
headerInfo, errWithCode := p.UpdateHeader(ctx,
account,
form.Header,
form.HeaderDescription,
)
if errWithCode != nil {
return nil, errWithCode
}
account.HeaderMediaAttachmentID = headerInfo.ID
account.HeaderMediaAttachment = 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
if err := p.state.DB.UpdateAttachment(
ctx,
account.HeaderMediaAttachment,
"description",
); err != nil {
err := gtserror.Newf("db error updating account avatar description: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
// 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, 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, err.Error())
}
priv := apimodel.Visibility(*form.Source.Privacy)
account.Settings.Privacy = typeutils.APIVisToVis(priv)
settingsColumns = append(settingsColumns, "privacy")
}
if form.Source.StatusContentType != nil {
if err := validate.StatusContentType(*form.Source.StatusContentType); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.StatusContentType = *form.Source.StatusContentType
settingsColumns = append(settingsColumns, "status_content_type")
}
}
if form.Theme != nil {
theme := *form.Theme
if theme == "" {
// Empty is easy, just clear this.
account.Settings.Theme = ""
} else {
// Theme was provided, check
// against known available themes.
if _, ok := p.themes.ByFileName[theme]; !ok {
err := fmt.Errorf("theme %s not available on this instance, see /api/v1/accounts/themes for available themes", theme)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.Theme = theme
}
settingsColumns = append(settingsColumns, "theme")
}
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
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")
}
// 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,
GTSModel: account,
Origin: account,
})
acctSensitive, err := p.converter.AccountToAPIAccountSensitive(ctx, account)
if err != nil {
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 ( var (
fieldsAttributes = *form.FieldsAttributes
fieldsLen = len(fieldsAttributes) fieldsLen = len(fieldsAttributes)
fieldsRaw = make([]*gtsmodel.Field, 0, fieldsLen) fieldsRaw = make([]*gtsmodel.Field, 0, fieldsLen)
) )
@ -126,18 +364,23 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
// Check length of parsed raw fields. // Check length of parsed raw fields.
if err := validate.ProfileFields(fieldsRaw); err != nil { if err := validate.ProfileFields(fieldsRaw); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return gtserror.NewErrorBadRequest(err, err.Error())
} }
// OK, new raw fields are valid. // OK, new raw fields are valid.
account.FieldsRaw = fieldsRaw account.FieldsRaw = fieldsRaw
account.Fields = make([]*gtsmodel.Field, 0, fieldsLen) // process these in a sec account.Fields = make([]*gtsmodel.Field, 0, fieldsLen)
return nil
}
// If fields have changed, account emojis may also have changed. // processAccountText processes the raw versions of the given
emojisChanged = true // 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.
if emojisChanged { func (p *Processor) processAccountText(
ctx context.Context,
account *gtsmodel.Account,
) {
// Use map to deduplicate emojis by their ID. // Use map to deduplicate emojis by their ID.
emojis := make(map[string]*gtsmodel.Emoji) emojis := make(map[string]*gtsmodel.Emoji)
@ -162,7 +405,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
emojis[emoji.ID] = emoji emojis[emoji.ID] = emoji
} }
// Process the raw fields we stored earlier. // Process raw fields.
account.Fields = make([]*gtsmodel.Field, 0, len(account.FieldsRaw)) account.Fields = make([]*gtsmodel.Field, 0, len(account.FieldsRaw))
for _, fieldRaw := range account.FieldsRaw { for _, fieldRaw := range account.FieldsRaw {
field := &gtsmodel.Field{} field := &gtsmodel.Field{}
@ -194,6 +437,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.Fields = append(account.Fields, field) account.Fields = append(account.Fields, field)
} }
// Update the account's emojis.
emojisCount := len(emojis) emojisCount := len(emojis)
account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount) account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount)
account.EmojiIDs = make([]string, 0, emojisCount) account.EmojiIDs = make([]string, 0, emojisCount)
@ -202,154 +446,6 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.Emojis = append(account.Emojis, emoji) account.Emojis = append(account.Emojis, emoji)
account.EmojiIDs = append(account.EmojiIDs, id) account.EmojiIDs = append(account.EmojiIDs, id)
} }
}
if form.AvatarDescription != nil {
desc := text.SanitizeToPlaintext(*form.AvatarDescription)
form.AvatarDescription = util.Ptr(desc)
}
if form.Avatar != nil && form.Avatar.Size != 0 {
avatarInfo, errWithCode := p.UpdateAvatar(ctx,
account,
form.Avatar,
form.AvatarDescription,
)
if errWithCode != nil {
return nil, errWithCode
}
account.AvatarMediaAttachmentID = avatarInfo.ID
account.AvatarMediaAttachment = avatarInfo
log.Tracef(ctx, "new avatar info for account %s is %+v", account.ID, avatarInfo)
} else if form.AvatarDescription != nil && account.AvatarMediaAttachment != nil {
// Update just existing description if possible.
account.AvatarMediaAttachment.Description = *form.AvatarDescription
if err := p.state.DB.UpdateAttachment(
ctx,
account.AvatarMediaAttachment,
"description",
); err != nil {
err := gtserror.Newf("db error updating account avatar description: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
if form.HeaderDescription != nil {
desc := text.SanitizeToPlaintext(*form.HeaderDescription)
form.HeaderDescription = util.Ptr(desc)
}
if form.Header != nil && form.Header.Size != 0 {
headerInfo, errWithCode := p.UpdateHeader(ctx,
account,
form.Header,
form.HeaderDescription,
)
if errWithCode != nil {
return nil, errWithCode
}
account.HeaderMediaAttachmentID = headerInfo.ID
account.HeaderMediaAttachment = headerInfo
log.Tracef(ctx, "new header info for account %s is %+v", account.ID, headerInfo)
} else if form.HeaderDescription != nil && account.HeaderMediaAttachment != nil {
// Update just existing description if possible.
account.HeaderMediaAttachment.Description = *form.HeaderDescription
if err := p.state.DB.UpdateAttachment(
ctx,
account.HeaderMediaAttachment,
"description",
); err != nil {
err := gtserror.Newf("db error updating account avatar description: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
if form.Locked != nil {
account.Locked = form.Locked
}
if form.Source != nil {
if form.Source.Language != nil {
language, err := validate.Language(*form.Source.Language)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err)
}
account.Settings.Language = language
}
if form.Source.Sensitive != nil {
account.Settings.Sensitive = form.Source.Sensitive
}
if form.Source.Privacy != nil {
if err := validate.Privacy(*form.Source.Privacy); err != nil {
return nil, gtserror.NewErrorBadRequest(err)
}
privacy := typeutils.APIVisToVis(apimodel.Visibility(*form.Source.Privacy))
account.Settings.Privacy = privacy
}
if form.Source.StatusContentType != nil {
if err := validate.StatusContentType(*form.Source.StatusContentType); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.StatusContentType = *form.Source.StatusContentType
}
}
if form.Theme != nil {
theme := *form.Theme
if theme == "" {
// Empty is easy, just clear this.
account.Settings.Theme = ""
} else {
// Theme was provided, check
// against known available themes.
if _, ok := p.themes.ByFileName[theme]; !ok {
err := fmt.Errorf("theme %s not available on this instance, see /api/v1/accounts/themes for available themes", theme)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.Theme = theme
}
}
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.CustomCSS(customCSS); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.CustomCSS = text.SanitizeToPlaintext(customCSS)
}
if form.EnableRSS != nil {
account.Settings.EnableRSS = form.EnableRSS
}
if form.HideCollections != nil {
account.Settings.HideCollections = form.HideCollections
}
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 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))
}
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityUpdate,
GTSModel: account,
Origin: account,
})
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))
}
return acctSensitive, nil
} }
// UpdateAvatar does the dirty work of checking the avatar // UpdateAvatar does the dirty work of checking the avatar

View file

@ -38,6 +38,8 @@ func APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility {
return gtsmodel.VisibilityMutualsOnly return gtsmodel.VisibilityMutualsOnly
case apimodel.VisibilityDirect: case apimodel.VisibilityDirect:
return gtsmodel.VisibilityDirect return gtsmodel.VisibilityDirect
case apimodel.VisibilityNone:
return gtsmodel.VisibilityNone
} }
return "" return ""
} }

View file

@ -134,6 +134,7 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode
apiAccount.Source = &apimodel.Source{ apiAccount.Source = &apimodel.Source{
Privacy: c.VisToAPIVis(ctx, a.Settings.Privacy), Privacy: c.VisToAPIVis(ctx, a.Settings.Privacy),
WebVisibility: c.VisToAPIVis(ctx, a.Settings.WebVisibility),
Sensitive: *a.Settings.Sensitive, Sensitive: *a.Settings.Sensitive,
Language: a.Settings.Language, Language: a.Settings.Language,
StatusContentType: statusContentType, StatusContentType: statusContentType,

View file

@ -120,6 +120,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved()
"fields": [], "fields": [],
"source": { "source": {
"privacy": "public", "privacy": "public",
"web_visibility": "unlisted",
"sensitive": false, "sensitive": false,
"language": "en", "language": "en",
"status_content_type": "text/plain", "status_content_type": "text/plain",
@ -304,6 +305,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
"fields": [], "fields": [],
"source": { "source": {
"privacy": "public", "privacy": "public",
"web_visibility": "unlisted",
"sensitive": false, "sensitive": false,
"language": "en", "language": "en",
"status_content_type": "text/plain", "status_content_type": "text/plain",

View file

@ -658,6 +658,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
Language: "en", Language: "en",
EnableRSS: util.Ptr(false), EnableRSS: util.Ptr(false),
HideCollections: util.Ptr(false), HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityPublic,
}, },
"admin_account": { "admin_account": {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
@ -668,6 +669,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
Language: "en", Language: "en",
EnableRSS: util.Ptr(true), EnableRSS: util.Ptr(true),
HideCollections: util.Ptr(false), HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityPublic,
}, },
"local_account_1": { "local_account_1": {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
@ -678,6 +680,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
Language: "en", Language: "en",
EnableRSS: util.Ptr(true), EnableRSS: util.Ptr(true),
HideCollections: util.Ptr(false), HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityUnlocked,
}, },
"local_account_2": { "local_account_2": {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
@ -688,6 +691,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
Language: "fr", Language: "fr",
EnableRSS: util.Ptr(false), EnableRSS: util.Ptr(false),
HideCollections: util.Ptr(true), HideCollections: util.Ptr(true),
WebVisibility: gtsmodel.VisibilityPublic,
}, },
} }
} }

View file

@ -115,6 +115,7 @@ function UserProfileForm({ data: profile }) {
discoverable: useBoolInput("discoverable", { source: profile}), discoverable: useBoolInput("discoverable", { source: profile}),
enableRSS: useBoolInput("enable_rss", { source: profile }), enableRSS: useBoolInput("enable_rss", { source: profile }),
hideCollections: useBoolInput("hide_collections", { source: profile }), hideCollections: useBoolInput("hide_collections", { source: profile }),
webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p) => p.source?.web_visibility }),
fields: useFieldArrayInput("fields_attributes", { fields: useFieldArrayInput("fields_attributes", {
defaultValue: profile?.source?.fields, defaultValue: profile?.source?.fields,
length: instanceConfig.maxPinnedFields length: instanceConfig.maxPinnedFields
@ -233,21 +234,32 @@ function UserProfileForm({ data: profile }) {
Learn more about these settings (opens in a new tab) Learn more about these settings (opens in a new tab)
</a> </a>
</div> </div>
<Select
field={form.webVisibility}
label="Visibility level of posts to show on your profile, and in your RSS feed (if enabled)."
options={
<>
<option value="public">Show Public posts only (the GoToSocial default)</option>
<option value="unlisted">Show Public and Unlisted posts (the Mastodon default)</option>
<option value="none">Show no posts</option>
</>
}
/>
<Checkbox <Checkbox
field={form.locked} field={form.locked}
label="Manually approve follow requests" label="Manually approve follow requests."
/> />
<Checkbox <Checkbox
field={form.discoverable} field={form.discoverable}
label="Mark account as discoverable by search engines and directories" label="Mark account as discoverable by search engines and directories."
/> />
<Checkbox <Checkbox
field={form.enableRSS} field={form.enableRSS}
label="Enable RSS feed of Public posts" label="Enable RSS feed of posts."
/> />
<Checkbox <Checkbox
field={form.hideCollections} field={form.hideCollections}
label="Hide who you follow / are followed by" label="Hide who you follow / are followed by."
/> />
<div className="form-section-docs"> <div className="form-section-docs">