diff options
| author | 2025-07-09 16:50:25 +0200 | |
|---|---|---|
| committer | 2025-07-09 16:50:25 +0200 | |
| commit | dcfc9b7885e7ed4f7886a35ccb3e007c293d3521 (patch) | |
| tree | 3bef2a25b9c8178bdfbece05a5165c94a5a7ae03 /internal | |
| parent | [performance] use our own typed value context types for Value() key checking ... (diff) | |
| download | gotosocial-dcfc9b7885e7ed4f7886a35ccb3e007c293d3521.tar.xz | |
[feature] Use `hidesToPublicFromUnauthedWeb` and `hidesCcPublicFromUnauthedWeb` properties for web visibility of statuses (#4315)
This pull request implements two new properties on ActivityPub actors: `hidesToPublicFromUnauthedWeb` and `hidesCcPublicFromUnauthedWeb`.
As documented, these properties allow actors to signal their preference for whether or not their posts should be hidden from unauthenticated web views (ie., web pages like the GtS frontend, web apps like the Mastodon frontend, web APIs like the Mastodon public timeline API, etc). This allows remote accounts to *opt in* to having their unlisted visibility posts shown in (for example) the replies section of the web view of a GtS thread. In future, we can also use these properties to determine whether we should show boosts of a remote actor's post on a GtS profile, and that sort of thing.
In keeping with our stance around privacy by default, GtS assumes `true` for `hidesCcPublicFromUnauthedWeb` if the property is not set on a remote actor, ie., hide unlisted/unlocked posts by default. `hidesToPublicFromUnauthedWeb` is assumed to be `false` if the property is not set on a remote actor, ie., show public posts by default.
~~WIP as I still want to work on the documentation for this a bit.~~
New props are already in the namespace document: https://gotosocial.org/ns
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4315
Reviewed-by: kim <gruf@noreply.codeberg.org>
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
Diffstat (limited to 'internal')
20 files changed, 616 insertions, 98 deletions
diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index faf793bd8..ec961f80b 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -227,6 +227,8 @@ type Accountable interface { WithMovedTo WithAlsoKnownAs WithManuallyApprovesFollowers + WithHidesToPublicFromUnauthedWeb + WithHidesCcPublicFromUnauthedWeb WithEndpoints WithTag WithPublished @@ -711,6 +713,18 @@ type WithManuallyApprovesFollowers interface { SetActivityStreamsManuallyApprovesFollowers(vocab.ActivityStreamsManuallyApprovesFollowersProperty) } +// WithHidesToPublicFromUnauthedWeb represents a Person or profile with the hidesToPublicFromUnauthedWeb property. +type WithHidesToPublicFromUnauthedWeb interface { + GetGoToSocialHidesToPublicFromUnauthedWeb() vocab.GoToSocialHidesToPublicFromUnauthedWebProperty + SetGoToSocialHidesToPublicFromUnauthedWeb(vocab.GoToSocialHidesToPublicFromUnauthedWebProperty) +} + +// WithHidesCcPublicFromUnauthedWeb represents a Person or profile with the hidesCcPublicFromUnauthedWeb property. +type WithHidesCcPublicFromUnauthedWeb interface { + GetGoToSocialHidesCcPublicFromUnauthedWeb() vocab.GoToSocialHidesCcPublicFromUnauthedWebProperty + SetGoToSocialHidesCcPublicFromUnauthedWeb(vocab.GoToSocialHidesCcPublicFromUnauthedWebProperty) +} + // WithEndpoints represents a Person or profile with the endpoints property type WithEndpoints interface { GetActivityStreamsEndpoints() vocab.ActivityStreamsEndpointsProperty diff --git a/internal/ap/properties.go b/internal/ap/properties.go index 722c3fca5..3e064bae0 100644 --- a/internal/ap/properties.go +++ b/internal/ap/properties.go @@ -562,6 +562,48 @@ func SetManuallyApprovesFollowers(with WithManuallyApprovesFollowers, manuallyAp mafProp.Set(manuallyApprovesFollowers) } +// GetHidesToPublicFromUnauthedWeb returns the boolean contained in the hidesToPublicFromUnauthedWeb property of 'with'. +// +// Returns default 'false' if property unusable or not set. +func GetHidesToPublicFromUnauthedWeb(with WithHidesToPublicFromUnauthedWeb) bool { + hidesProp := with.GetGoToSocialHidesToPublicFromUnauthedWeb() + if hidesProp == nil || !hidesProp.IsXMLSchemaBoolean() { + return false + } + return hidesProp.Get() +} + +// SetHidesToPublicFromUnauthedWeb sets the given boolean on the hidesToPublicFromUnauthedWeb property of 'with'. +func SetHidesToPublicFromUnauthedWeb(with WithHidesToPublicFromUnauthedWeb, hidesToPublicFromUnauthedWeb bool) { + hidesProp := with.GetGoToSocialHidesToPublicFromUnauthedWeb() + if hidesProp == nil { + hidesProp = streams.NewGoToSocialHidesToPublicFromUnauthedWebProperty() + with.SetGoToSocialHidesToPublicFromUnauthedWeb(hidesProp) + } + hidesProp.Set(hidesToPublicFromUnauthedWeb) +} + +// GetHidesCcPublicFromUnauthedWeb returns the boolean contained in the hidesCcPublicFromUnauthedWeb property of 'with'. +// +// Returns default 'true' if property unusable or not set. +func GetHidesCcPublicFromUnauthedWeb(with WithHidesCcPublicFromUnauthedWeb) bool { + hidesProp := with.GetGoToSocialHidesCcPublicFromUnauthedWeb() + if hidesProp == nil || !hidesProp.IsXMLSchemaBoolean() { + return true + } + return hidesProp.Get() +} + +// SetHidesCcPublicFromUnauthedWeb sets the given boolean on the hidesCcPublicFromUnauthedWeb property of 'with'. +func SetHidesCcPublicFromUnauthedWeb(with WithHidesCcPublicFromUnauthedWeb, hidesCcPublicFromUnauthedWeb bool) { + hidesProp := with.GetGoToSocialHidesCcPublicFromUnauthedWeb() + if hidesProp == nil { + hidesProp = streams.NewGoToSocialHidesCcPublicFromUnauthedWebProperty() + with.SetGoToSocialHidesCcPublicFromUnauthedWeb(hidesProp) + } + hidesProp.Set(hidesCcPublicFromUnauthedWeb) +} + // GetApprovedBy returns the URL contained in // the ApprovedBy property of 'with', if set. func GetApprovedBy(with WithApprovedBy) *url.URL { diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 66dc3b307..603740f17 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -1054,10 +1054,21 @@ func (a *accountDB) GetAccountWebStatuses( return nil, nil } - // Check for an easy case: account exposes no statuses via the web. - webVisibility := account.Settings.WebVisibility - if webVisibility == gtsmodel.VisibilityNone { - return nil, db.ErrNoEntries + // Derive visibility of statuses on the web. + // + // We don't account for situations where someone + // hides public statuses but shows unlocked/unlisted, + // since that's only an option for remote accts. + var ( + hideAll = *account.HidesToPublicFromUnauthedWeb + publicOnly = *account.HidesCcPublicFromUnauthedWeb + ) + + if hideAll { + // Account hides all + // statuses from web, + // nothing to do. + return nil, nil } // Ensure reasonable @@ -1075,27 +1086,18 @@ func (a *accountDB) GetAccountWebStatuses( Column("status.id"). Where("? = ?", bun.Ident("status.account_id"), account.ID) - // Select statuses for this account according - // to their web visibility preference. - switch webVisibility { - - case gtsmodel.VisibilityPublic: + // Select statuses according to + // account's web visibility prefs. + if publicOnly { // Only Public statuses. q = q.Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic) - - case gtsmodel.VisibilityUnlocked: + } else { // 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 replies, boosts, or diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go index 0666cf8a8..dcf51c6a5 100644 --- a/internal/db/bundb/admin.go +++ b/internal/db/bundb/admin.go @@ -120,20 +120,21 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) ( } account = >smodel.Account{ - ID: accountID, - Username: newSignup.Username, - DisplayName: newSignup.Username, - URI: uris.UserURI, - URL: uris.UserURL, - InboxURI: uris.InboxURI, - OutboxURI: uris.OutboxURI, - FollowingURI: uris.FollowingURI, - FollowersURI: uris.FollowersURI, - FeaturedCollectionURI: uris.FeaturedCollectionURI, - ActorType: gtsmodel.AccountActorTypePerson, - PrivateKey: privKey, - PublicKey: &privKey.PublicKey, - PublicKeyURI: uris.PublicKeyURI, + ID: accountID, + Username: newSignup.Username, + DisplayName: newSignup.Username, + URI: uris.UserURI, + URL: uris.UserURL, + InboxURI: uris.InboxURI, + OutboxURI: uris.OutboxURI, + FollowingURI: uris.FollowingURI, + FollowersURI: uris.FollowersURI, + FeaturedCollectionURI: uris.FeaturedCollectionURI, + ActorType: gtsmodel.AccountActorTypePerson, + PrivateKey: privKey, + PublicKey: &privKey.PublicKey, + PublicKeyURI: uris.PublicKeyURI, + HidesCcPublicFromUnauthedWeb: util.Ptr(true), // GtS default to hide unlisted. } // Insert the new account! diff --git a/internal/db/bundb/migrations/20250708074906_unauthed_web_updates.go b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates.go new file mode 100644 index 000000000..f69dbac86 --- /dev/null +++ b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates.go @@ -0,0 +1,164 @@ +// 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" + "fmt" + "reflect" + + "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common" + newmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/new" + oldmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/old" + "code.superseriousbusiness.org/gotosocial/internal/log" + "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 { + + var account *newmodel.Account + accountType := reflect.TypeOf(account) + + // Add new columns to accounts + // table if they don't exist already. + for _, new := range []struct { + dbCol string + fieldName string + }{ + { + dbCol: "hides_to_public_from_unauthed_web", + fieldName: "HidesToPublicFromUnauthedWeb", + }, + { + dbCol: "hides_cc_public_from_unauthed_web", + fieldName: "HidesCcPublicFromUnauthedWeb", + }, + } { + exists, err := doesColumnExist( + ctx, + tx, + "accounts", + new.dbCol, + ) + if err != nil { + return err + } + + if exists { + // Column already exists. + continue + } + + // Column doesn't exist yet, add it. + colDef, err := getBunColumnDef(tx, accountType, new.fieldName) + if err != nil { + return fmt.Errorf("error making column def: %w", err) + } + + log.Infof(ctx, "adding accounts.%s column...", new.dbCol) + if _, err := tx. + NewAddColumn(). + Model(account). + ColumnExpr(colDef). + Exec(ctx); err != nil { + return fmt.Errorf("error adding column: %w", err) + } + } + + // For each account settings we have + // stored on this instance, set the + // new account columns to values + // corresponding to the setting. + allSettings := []*oldmodel.AccountSettings{} + if err := tx. + NewSelect(). + Model(&allSettings). + Column("account_id", "web_visibility"). + Scan(ctx); err != nil { + return fmt.Errorf("error selecting settings: %w", err) + } + + for _, settings := range allSettings { + + // Derive web visibility. + var ( + hidesToPublicFromUnauthedWeb bool + hidesCcPublicFromUnauthedWeb bool + ) + + switch settings.WebVisibility { + + // Show nothing. + case common.VisibilityNone: + hidesToPublicFromUnauthedWeb = true + hidesCcPublicFromUnauthedWeb = true + + // Show public only (GtS default). + case common.VisibilityPublic: + hidesToPublicFromUnauthedWeb = false + hidesCcPublicFromUnauthedWeb = true + + // Show public + unlisted (Masto default). + case common.VisibilityUnlocked: + hidesToPublicFromUnauthedWeb = false + hidesCcPublicFromUnauthedWeb = false + + default: + log.Warnf(ctx, + "local account %s had unrecognized settings.WebVisibility %d, skipping...", + settings.AccountID, settings.WebVisibility, + ) + continue + } + + // Update account. + if _, err := tx. + NewUpdate(). + Table("accounts"). + Set("? = ?", bun.Ident("hides_to_public_from_unauthed_web"), hidesToPublicFromUnauthedWeb). + Set("? = ?", bun.Ident("hides_cc_public_from_unauthed_web"), hidesCcPublicFromUnauthedWeb). + Where("? = ?", bun.Ident("id"), settings.AccountID).Exec(ctx); err != nil { + return fmt.Errorf("error updating local account: %w", err) + } + } + + // Drop the old web_visibility column. + if _, err := tx. + NewDropColumn(). + Model((*oldmodel.AccountSettings)(nil)). + Column("web_visibility"). + Exec(ctx); err != nil { + return fmt.Errorf("error dropping old web_visibility column: %w", err) + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common/visibility.go b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common/visibility.go new file mode 100644 index 000000000..51cb13d87 --- /dev/null +++ b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common/visibility.go @@ -0,0 +1,50 @@ +// 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 common + +// Visibility represents the +// visibility granularity of a status. +type Visibility int16 + +const ( + // VisibilityNone means nobody can see this. + // It's only used for web status visibility. + VisibilityNone Visibility = 1 + + // VisibilityPublic means this status will + // be visible to everyone on all timelines. + VisibilityPublic Visibility = 2 + + // VisibilityUnlocked means this status will be visible to everyone, + // but will only show on home timeline to followers, and in lists. + VisibilityUnlocked Visibility = 3 + + // VisibilityFollowersOnly means this status is viewable to followers only. + VisibilityFollowersOnly Visibility = 4 + + // VisibilityMutualsOnly means this status + // is visible to mutual followers only. + VisibilityMutualsOnly Visibility = 5 + + // VisibilityDirect means this status is + // visible only to mentioned recipients. + VisibilityDirect Visibility = 6 + + // VisibilityDefault is used when no other setting can be found. + VisibilityDefault Visibility = VisibilityUnlocked +) diff --git a/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/new/account.go b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/new/account.go new file mode 100644 index 000000000..ad25a5bc9 --- /dev/null +++ b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/new/account.go @@ -0,0 +1,102 @@ +// 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 gtsmodel + +import ( + "crypto/rsa" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common" +) + +type Account struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + FetchedAt time.Time `bun:"type:timestamptz,nullzero"` + Username string `bun:",nullzero,notnull,unique:accounts_username_domain_uniq"` + Domain string `bun:",nullzero,unique:accounts_username_domain_uniq"` + AvatarMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` + AvatarRemoteURL string `bun:",nullzero"` + HeaderMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` + HeaderRemoteURL string `bun:",nullzero"` + DisplayName string `bun:",nullzero"` + EmojiIDs []string `bun:"emojis,array"` + Fields []*Field `bun:",nullzero"` + FieldsRaw []*Field `bun:",nullzero"` + Note string `bun:",nullzero"` + NoteRaw string `bun:",nullzero"` + AlsoKnownAsURIs []string `bun:"also_known_as_uris,array"` + AlsoKnownAs []*Account `bun:"-"` + MovedToURI string `bun:",nullzero"` + MovedTo *Account `bun:"-"` + MoveID string `bun:"type:CHAR(26),nullzero"` + Locked *bool `bun:",nullzero,notnull,default:true"` + Discoverable *bool `bun:",nullzero,notnull,default:false"` + URI string `bun:",nullzero,notnull,unique"` + URL string `bun:",nullzero"` + InboxURI string `bun:",nullzero"` + SharedInboxURI *string `bun:""` + OutboxURI string `bun:",nullzero"` + FollowingURI string `bun:",nullzero"` + FollowersURI string `bun:",nullzero"` + FeaturedCollectionURI string `bun:",nullzero"` + ActorType int16 `bun:",nullzero,notnull"` + PrivateKey *rsa.PrivateKey `bun:""` + PublicKey *rsa.PublicKey `bun:",notnull"` + PublicKeyURI string `bun:",nullzero,notnull,unique"` + PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` + MemorializedAt time.Time `bun:"type:timestamptz,nullzero"` + SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` + SilencedAt time.Time `bun:"type:timestamptz,nullzero"` + SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` + SuspensionOrigin string `bun:"type:CHAR(26),nullzero"` + + // Added in this migration: + HidesToPublicFromUnauthedWeb *bool `bun:",nullzero,notnull,default:false"` + HidesCcPublicFromUnauthedWeb *bool `bun:",nullzero,notnull,default:false"` +} + +type Field struct { + Name string + Value string + VerifiedAt time.Time `bun:",nullzero"` +} + +type AccountSettings struct { + AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + Privacy common.Visibility `bun:",nullzero,default:3"` + Sensitive *bool `bun:",nullzero,notnull,default:false"` + Language string `bun:",nullzero,notnull,default:'en'"` + StatusContentType string `bun:",nullzero"` + Theme string `bun:",nullzero"` + CustomCSS string `bun:",nullzero"` + EnableRSS *bool `bun:",nullzero,notnull,default:false"` + HideCollections *bool `bun:",nullzero,notnull,default:false"` + WebLayout int16 `bun:",nullzero,notnull,default:1"` + InteractionPolicyDirect *struct{} `bun:""` + InteractionPolicyMutualsOnly *struct{} `bun:""` + InteractionPolicyFollowersOnly *struct{} `bun:""` + InteractionPolicyUnlocked *struct{} `bun:""` + InteractionPolicyPublic *struct{} `bun:""` + + // Removed in this migration: + // WebVisibility common.Visibility `bun:",nullzero,notnull,default:3"` +} diff --git a/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/old/account.go b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/old/account.go new file mode 100644 index 000000000..0086b1464 --- /dev/null +++ b/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/old/account.go @@ -0,0 +1,96 @@ +// 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 gtsmodel + +import ( + "crypto/rsa" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250708074906_unauthed_web_updates/common" +) + +type Account struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + FetchedAt time.Time `bun:"type:timestamptz,nullzero"` + Username string `bun:",nullzero,notnull,unique:accounts_username_domain_uniq"` + Domain string `bun:",nullzero,unique:accounts_username_domain_uniq"` + AvatarMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` + AvatarRemoteURL string `bun:",nullzero"` + HeaderMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` + HeaderRemoteURL string `bun:",nullzero"` + DisplayName string `bun:",nullzero"` + EmojiIDs []string `bun:"emojis,array"` + Fields []*Field `bun:",nullzero"` + FieldsRaw []*Field `bun:",nullzero"` + Note string `bun:",nullzero"` + NoteRaw string `bun:",nullzero"` + AlsoKnownAsURIs []string `bun:"also_known_as_uris,array"` + AlsoKnownAs []*Account `bun:"-"` + MovedToURI string `bun:",nullzero"` + MovedTo *Account `bun:"-"` + MoveID string `bun:"type:CHAR(26),nullzero"` + Locked *bool `bun:",nullzero,notnull,default:true"` + Discoverable *bool `bun:",nullzero,notnull,default:false"` + URI string `bun:",nullzero,notnull,unique"` + URL string `bun:",nullzero"` + InboxURI string `bun:",nullzero"` + SharedInboxURI *string `bun:""` + OutboxURI string `bun:",nullzero"` + FollowingURI string `bun:",nullzero"` + FollowersURI string `bun:",nullzero"` + FeaturedCollectionURI string `bun:",nullzero"` + ActorType int16 `bun:",nullzero,notnull"` + PrivateKey *rsa.PrivateKey `bun:""` + PublicKey *rsa.PublicKey `bun:",notnull"` + PublicKeyURI string `bun:",nullzero,notnull,unique"` + PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` + MemorializedAt time.Time `bun:"type:timestamptz,nullzero"` + SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` + SilencedAt time.Time `bun:"type:timestamptz,nullzero"` + SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` + SuspensionOrigin string `bun:"type:CHAR(26),nullzero"` +} + +type Field struct { + Name string + Value string + VerifiedAt time.Time `bun:",nullzero"` +} + +type AccountSettings struct { + AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + Privacy common.Visibility `bun:",nullzero,default:3"` + Sensitive *bool `bun:",nullzero,notnull,default:false"` + Language string `bun:",nullzero,notnull,default:'en'"` + StatusContentType string `bun:",nullzero"` + Theme string `bun:",nullzero"` + CustomCSS string `bun:",nullzero"` + EnableRSS *bool `bun:",nullzero,notnull,default:false"` + HideCollections *bool `bun:",nullzero,notnull,default:false"` + WebVisibility common.Visibility `bun:",nullzero,notnull,default:3"` + WebLayout int16 `bun:",nullzero,notnull,default:1"` + InteractionPolicyDirect *struct{} `bun:""` + InteractionPolicyMutualsOnly *struct{} `bun:""` + InteractionPolicyFollowersOnly *struct{} `bun:""` + InteractionPolicyUnlocked *struct{} `bun:""` + InteractionPolicyPublic *struct{} `bun:""` +} diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go index 24fa6f2e6..c46fd369c 100644 --- a/internal/filter/visibility/status.go +++ b/internal/filter/visibility/status.go @@ -115,9 +115,7 @@ func (f *Filter) isStatusVisible( if requester == nil { // Use a different visibility // heuristic for unauthed requests. - return f.isStatusVisibleUnauthed( - ctx, status, - ) + return f.isStatusVisibleUnauthed(status), nil } /* @@ -245,57 +243,29 @@ func isPendingStatusVisible(requester *gtsmodel.Account, status *gtsmodel.Status return false } -// isStatusVisibleUnauthed returns whether status is visible without any unauthenticated account. -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 - } +// isStatusVisibleUnauthed returns whether status is visible without authentication. +func (f *Filter) isStatusVisibleUnauthed(status *gtsmodel.Status) bool { // If status is local only, - // never show via the web. + // never show without auth. 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, - ) - } + return false } - switch webvis := status.Account.Settings.WebVisibility; webvis { + switch status.Visibility { - // public_only: status must be Public. case gtsmodel.VisibilityPublic: - return status.Visibility == gtsmodel.VisibilityPublic, nil + // Visible if account doesn't hide Public statuses. + return !*status.Account.HidesToPublicFromUnauthedWeb - // unlisted: status must be Public or Unlocked. case gtsmodel.VisibilityUnlocked: - visible := status.Visibility == gtsmodel.VisibilityPublic || - status.Visibility == gtsmodel.VisibilityUnlocked - return visible, nil + // Visible if account doesn't hide Unlocked statuses. + return !*status.Account.HidesCcPublicFromUnauthedWeb - // 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, webvis, - ) + // For all other visibilities, + // never show without auth. + return false } } diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 664f1f66a..8b2de6b23 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -272,6 +272,16 @@ type Account struct { // // Local accounts only. Stats *AccountStats `bun:"-"` + + // True if the actor hides to-public statusables + // from unauthenticated public access via the web. + // Default "false" if not set on the actor model. + HidesToPublicFromUnauthedWeb *bool `bun:",nullzero,notnull,default:false"` + + // True if the actor hides cc-public statusables + // from unauthenticated public access via the web. + // Default "true" if not set on the actor model. + HidesCcPublicFromUnauthedWeb *bool `bun:",nullzero,notnull,default:true"` } // UsernameDomain returns account @username@domain (missing domain if local). diff --git a/internal/gtsmodel/accountsettings.go b/internal/gtsmodel/accountsettings.go index 30fb7e5df..9fa05e139 100644 --- a/internal/gtsmodel/accountsettings.go +++ b/internal/gtsmodel/accountsettings.go @@ -35,7 +35,6 @@ 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:3"` // Visibility level of statuses that visitors can view via the web profile. WebLayout WebLayout `bun:",nullzero,notnull,default:1"` // Layout to use when showing this profile via the web. 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. diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index f0e3b790b..99dd074a5 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -212,6 +212,37 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } } + if form.WebVisibility != nil { + switch apimodel.Visibility(*form.WebVisibility) { + + // Show none. + case apimodel.VisibilityNone: + account.HidesToPublicFromUnauthedWeb = util.Ptr(true) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(true) + + // Show public only (GtS default). + case apimodel.VisibilityPublic: + account.HidesToPublicFromUnauthedWeb = util.Ptr(false) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(true) + + // Show public and unlisted (Masto default). + case apimodel.VisibilityUnlisted: + account.HidesToPublicFromUnauthedWeb = util.Ptr(false) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(false) + + default: + const text = "web_visibility must be one of public, unlisted, or none" + err := errors.New(text) + return nil, gtserror.NewErrorBadRequest(err, text) + } + + acctColumns = append( + acctColumns, + "hides_to_public_from_unauthed_web", + "hides_cc_public_from_unauthed_web", + ) + } + // Account settings flags. if form.Source != nil { @@ -287,21 +318,6 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form 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 form.WebLayout != nil { webLayout := gtsmodel.ParseWebLayout(*form.WebLayout) if webLayout == gtsmodel.WebLayoutUnknown { diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go index 3320a45da..cf3ade7e5 100644 --- a/internal/processing/timeline/public_test.go +++ b/internal/processing/timeline/public_test.go @@ -67,7 +67,7 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { ctx = suite.T().Context() requester = suite.testAccounts["local_account_1"] // Select 1 *just above* a status we know should - // not be in the public timeline -- a public + // not be in the public timeline -- an unlisted // reply to one of admin's statuses. maxID = "01HE7XJ1CG84TBKH5V9XKBVGF6" sinceID = "" @@ -91,9 +91,9 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { // some other statuses were filtered out. suite.NoError(errWithCode) suite.Len(resp.Items, 1) - suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5>; rel="prev"`, resp.LinkHeader) + suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01FF25D5Q0DH7CHD57CTRS6WK0>; rel="prev"`, resp.LinkHeader) suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV`, resp.NextLink) - suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5`, resp.PrevLink) + suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01FF25D5Q0DH7CHD57CTRS6WK0`, resp.PrevLink) } // A timeline containing a status hidden due to filtering should return other statuses with no error. diff --git a/internal/transport/dereference_test.go b/internal/transport/dereference_test.go index b9611d1e7..836ba2ba5 100644 --- a/internal/transport/dereference_test.go +++ b/internal/transport/dereference_test.go @@ -43,8 +43,8 @@ func (suite *DereferenceTestSuite) TestDerefLocalUser() { defer resp.Body.Close() suite.Equal(http.StatusOK, resp.StatusCode) - suite.EqualValues(2007, resp.ContentLength) - suite.Equal("2007", resp.Header.Get("Content-Length")) + suite.EqualValues(2109, resp.ContentLength) + suite.Equal("2109", resp.Header.Get("Content-Length")) suite.Equal(apiutil.AppActivityLDJSON, resp.Header.Get("Content-Type")) b, err := io.ReadAll(resp.Body) @@ -59,6 +59,7 @@ func (suite *DereferenceTestSuite) TestDerefLocalUser() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -75,6 +76,8 @@ func (suite *DereferenceTestSuite) TestDerefLocalUser() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 35f1e7cb7..ede2b3253 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -244,6 +244,10 @@ func (c *Converter) ASRepresentationToAccount( acct.PublicKey = pkey acct.PublicKeyURI = pkeyURL.String() + // Web visibility for statuses. + acct.HidesToPublicFromUnauthedWeb = util.Ptr(ap.GetHidesToPublicFromUnauthedWeb(accountable)) + acct.HidesCcPublicFromUnauthedWeb = util.Ptr(ap.GetHidesCcPublicFromUnauthedWeb(accountable)) + return &acct, nil } diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 3feecaa9b..ce1501e1a 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -399,6 +399,10 @@ func (c *Converter) AccountToAS( } } + // Web visibility for statuses. + ap.SetHidesToPublicFromUnauthedWeb(accountable, *a.HidesToPublicFromUnauthedWeb) + ap.SetHidesCcPublicFromUnauthedWeb(accountable, *a.HidesCcPublicFromUnauthedWeb) + return accountable, nil } diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 5da103582..f3e19bb81 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -48,6 +48,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -64,6 +65,8 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", @@ -116,6 +119,7 @@ func (suite *InternalToASTestSuite) TestAccountToASBot() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -132,6 +136,8 @@ func (suite *InternalToASTestSuite) TestAccountToASBot() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", @@ -178,6 +184,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -209,6 +216,8 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { "featured": "http://localhost:8080/users/1happyturtle/collections/featured", "followers": "http://localhost:8080/users/1happyturtle/followers", "following": "http://localhost:8080/users/1happyturtle/following", + "hidesCcPublicFromUnauthedWeb": true, + "hidesToPublicFromUnauthedWeb": false, "id": "http://localhost:8080/users/1happyturtle", "inbox": "http://localhost:8080/users/1happyturtle/inbox", "manuallyApprovesFollowers": true, @@ -256,6 +265,7 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -279,6 +289,8 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", @@ -328,6 +340,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { // Despite only one field being set, attachments should still be a slice/array. suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -354,6 +367,8 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { "featured": "http://localhost:8080/users/1happyturtle/collections/featured", "followers": "http://localhost:8080/users/1happyturtle/followers", "following": "http://localhost:8080/users/1happyturtle/following", + "hidesCcPublicFromUnauthedWeb": true, + "hidesToPublicFromUnauthedWeb": false, "id": "http://localhost:8080/users/1happyturtle", "inbox": "http://localhost:8080/users/1happyturtle/inbox", "manuallyApprovesFollowers": true, @@ -389,6 +404,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -406,6 +422,8 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", @@ -464,6 +482,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -483,6 +502,8 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 3b5af6579..aef38ad6e 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -134,9 +134,26 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode statusContentType = a.Settings.StatusContentType } + // Derive web visibility for + // this local account's statuses. + var webVisibility apimodel.Visibility + switch { + case *a.HidesToPublicFromUnauthedWeb: + // Hides all. + webVisibility = apimodel.VisibilityNone + + case !*a.HidesCcPublicFromUnauthedWeb: + // Shows unlisted + public (Masto default). + webVisibility = apimodel.VisibilityUnlisted + + default: + // Shows public only (GtS default). + webVisibility = apimodel.VisibilityPublic + } + apiAccount.Source = &apimodel.Source{ Privacy: VisToAPIVis(a.Settings.Privacy), - WebVisibility: VisToAPIVis(a.Settings.WebVisibility), + WebVisibility: webVisibility, WebLayout: a.Settings.WebLayout.String(), Sensitive: *a.Settings.Sensitive, Language: a.Settings.Language, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 8b0d15f10..a26ff114e 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -965,7 +965,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", "sensitive": true, "spoiler_text": "some unknown media included", - "visibility": "public", + "visibility": "unlisted", "language": "en", "uri": "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", "url": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", @@ -1114,7 +1114,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", "sensitive": true, "spoiler_text": "some unknown media included", - "visibility": "public", + "visibility": "unlisted", "language": "en", "uri": "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", "url": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go index 828e6a683..c0c51b37a 100644 --- a/internal/typeutils/wrap_test.go +++ b/internal/typeutils/wrap_test.go @@ -178,6 +178,7 @@ func (suite *WrapTestSuite) TestWrapAccountableInUpdate() { suite.Equal(`{ "@context": [ + "https://gotosocial.org/ns", "https://w3id.org/security/v1", "https://www.w3.org/ns/activitystreams", { @@ -198,6 +199,8 @@ func (suite *WrapTestSuite) TestWrapAccountableInUpdate() { "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", + "hidesCcPublicFromUnauthedWeb": false, + "hidesToPublicFromUnauthedWeb": false, "icon": { "mediaType": "image/jpeg", "name": "a green goblin looking nasty", |
