From 5543fd53400037dc8ae22d4919b7085c46177ce1 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 9 Sep 2024 18:07:25 +0200
Subject: [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
---
internal/api/client/accounts/accountupdate.go | 12 +-
internal/api/model/account.go | 3 +
internal/api/model/source.go | 5 +
internal/api/model/status.go | 2 +
internal/db/account.go | 7 +-
internal/db/bundb/account.go | 67 +++-
.../20240906144432_unauthed_visibility.go.go | 69 ++++
internal/filter/visibility/account.go | 2 +-
internal/filter/visibility/filter.go | 4 +-
internal/filter/visibility/home_timeline.go | 2 +-
internal/filter/visibility/public_timeline.go | 2 +-
internal/filter/visibility/status.go | 64 +++-
internal/gtsmodel/accountsettings.go | 5 +-
internal/gtsmodel/status.go | 3 +
internal/processing/account/rss.go | 2 +-
internal/processing/account/statuses.go | 14 +-
internal/processing/account/update.go | 356 +++++++++++++--------
internal/typeutils/frontendtointernal.go | 2 +
internal/typeutils/internaltofrontend.go | 1 +
internal/typeutils/internaltofrontend_test.go | 2 +
20 files changed, 467 insertions(+), 157 deletions(-)
create mode 100644 internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go
(limited to 'internal')
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
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
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", -- cgit v1.2.3