From a55bd6d2bd7b11aed653f4614836caed4103bec3 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 31 Jan 2025 19:27:18 +0100 Subject: [PATCH] [feature] Add `instance-stats-randomize` config option (#3718) * [feature] Add `instance-stats-randomize` config option * don't use cache (overkill) --- docs/configuration/instance.md | 11 ++++ example/config.yaml | 11 ++++ internal/api/client/instance/instanceget.go | 13 +++++ internal/api/model/instance.go | 13 ++++- internal/api/model/instancev1.go | 7 +++ internal/api/model/instancev2.go | 7 +++ internal/config/config.go | 1 + internal/config/flags.go | 1 + internal/config/helpers.gen.go | 27 +++++++++- internal/processing/fedi/wellknown.go | 30 ++++++++--- internal/typeutils/converter.go | 58 +++++++++++++++++++++ internal/typeutils/internaltofrontend.go | 12 +++++ test/envparsing.sh | 2 + 13 files changed, 183 insertions(+), 10 deletions(-) diff --git a/docs/configuration/instance.md b/docs/configuration/instance.md index cc793b7fe..fdaf324cf 100644 --- a/docs/configuration/instance.md +++ b/docs/configuration/instance.md @@ -138,4 +138,15 @@ instance-subscriptions-process-from: "23:00" # Examples: ["24h", "72h", "12h"] # Default: "24h" (once per day). instance-subscriptions-process-every: "24h" + +# Bool. Set this to true to randomize stats served at +# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints. +# +# This can be useful when you don't want bots to obtain +# reliable information about the amount of users and +# statuses on your instance. +# +# Options: [true, false] +# Default: false +instance-stats-randomize: false ``` diff --git a/example/config.yaml b/example/config.yaml index 164eea7b2..10d7799c6 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -425,6 +425,17 @@ instance-subscriptions-process-from: "23:00" # Default: "24h" (once per day). instance-subscriptions-process-every: "24h" +# Bool. Set this to true to randomize stats served at +# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints. +# +# This can be useful when you don't want bots to obtain +# reliable information about the amount of users and +# statuses on your instance. +# +# Options: [true, false] +# Default: false +instance-stats-randomize: false + ########################### ##### ACCOUNTS CONFIG ##### ########################### diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go index 6690e7e98..d7a688b43 100644 --- a/internal/api/client/instance/instanceget.go +++ b/internal/api/client/instance/instanceget.go @@ -21,7 +21,9 @@ import ( "net/http" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/gin-gonic/gin" ) @@ -58,6 +60,12 @@ func (m *Module) InstanceInformationGETHandlerV1(c *gin.Context) { return } + if config.GetInstanceStatsRandomize() { + // Replace actual stats with cached randomized ones. + instance.Stats["user_count"] = util.Ptr(int(instance.RandomStats.TotalUsers)) + instance.Stats["status_count"] = util.Ptr(int(instance.RandomStats.Statuses)) + } + apiutil.JSON(c, http.StatusOK, instance) } @@ -93,5 +101,10 @@ func (m *Module) InstanceInformationGETHandlerV2(c *gin.Context) { return } + if config.GetInstanceStatsRandomize() { + // Replace actual stats with cached randomized ones. + instance.Usage.Users.ActiveMonth = int(instance.RandomStats.MonthlyActiveUsers) + } + apiutil.JSON(c, http.StatusOK, instance) } diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go index d59424fa5..aaa01d837 100644 --- a/internal/api/model/instance.go +++ b/internal/api/model/instance.go @@ -17,7 +17,10 @@ package model -import "mime/multipart" +import ( + "mime/multipart" + "time" +) // InstanceSettingsUpdateRequest models an instance update request. // @@ -148,3 +151,11 @@ type InstanceConfigurationEmojis struct { // example: 51200 EmojiSizeLimit int `json:"emoji_size_limit"` } + +// swagger:ignore +type RandomStats struct { + Statuses int64 + TotalUsers int64 + MonthlyActiveUsers int64 + Generated time.Time +} diff --git a/internal/api/model/instancev1.go b/internal/api/model/instancev1.go index 6dedd04cc..57e32c80a 100644 --- a/internal/api/model/instancev1.go +++ b/internal/api/model/instancev1.go @@ -110,6 +110,13 @@ type InstanceV1 struct { Terms string `json:"terms,omitempty"` // Raw (unparsed) version of terms. TermsRaw string `json:"terms_text,omitempty"` + + // Random stats generated for the instance. + // Only used if `instance-stats-randomize` is true. + // Not serialized to the frontend. + // + // swagger:ignore + RandomStats `json:"-"` } // InstanceV1URLs models instance-relevant URLs for client application consumption. diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go index b3d11dee2..96399ea06 100644 --- a/internal/api/model/instancev2.go +++ b/internal/api/model/instancev2.go @@ -74,6 +74,13 @@ type InstanceV2 struct { Terms string `json:"terms,omitempty"` // Raw (unparsed) version of terms. TermsText string `json:"terms_text,omitempty"` + + // Random stats generated for the instance. + // Only used if `instance-stats-randomize` is true. + // Not serialized to the frontend. + // + // swagger:ignore + RandomStats `json:"-"` } // Usage data for this instance. diff --git a/internal/config/config.go b/internal/config/config.go index 33b4553a8..807d686d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,6 +90,7 @@ type Configuration struct { InstanceLanguages language.Languages `name:"instance-languages" usage:"BCP47 language tags for the instance. Used to indicate the preferred languages of instance residents (in order from most-preferred to least-preferred)."` InstanceSubscriptionsProcessFrom string `name:"instance-subscriptions-process-from" usage:"Time of day from which to start running instance subscriptions processing jobs. Should be in the format 'hh:mm:ss', eg., '15:04:05'."` InstanceSubscriptionsProcessEvery time.Duration `name:"instance-subscriptions-process-every" usage:"Period to elapse between instance subscriptions processing jobs, starting from instance-subscriptions-process-from."` + InstanceStatsRandomize bool `name:"instance-stats-randomize" usage:"Set to true to randomize the stats served at api/v1/instance and api/v2/instance endpoints. Home page stats remain unchanged."` AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."` AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"` diff --git a/internal/config/flags.go b/internal/config/flags.go index 6f0957c36..b0b530d0b 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -92,6 +92,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) { cmd.Flags().StringSlice(InstanceLanguagesFlag(), cfg.InstanceLanguages.TagStrs(), fieldtag("InstanceLanguages", "usage")) cmd.Flags().String(InstanceSubscriptionsProcessFromFlag(), cfg.InstanceSubscriptionsProcessFrom, fieldtag("InstanceSubscriptionsProcessFrom", "usage")) cmd.Flags().Duration(InstanceSubscriptionsProcessEveryFlag(), cfg.InstanceSubscriptionsProcessEvery, fieldtag("InstanceSubscriptionsProcessEvery", "usage")) + cmd.Flags().Bool(InstanceStatsRandomizeFlag(), cfg.InstanceStatsRandomize, fieldtag("InstanceStatsRandomize", "usage")) // Accounts cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 0f8ec02ce..469c46a7a 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -1057,6 +1057,31 @@ func SetInstanceSubscriptionsProcessEvery(v time.Duration) { global.SetInstanceSubscriptionsProcessEvery(v) } +// GetInstanceStatsRandomize safely fetches the Configuration value for state's 'InstanceStatsRandomize' field +func (st *ConfigState) GetInstanceStatsRandomize() (v bool) { + st.mutex.RLock() + v = st.config.InstanceStatsRandomize + st.mutex.RUnlock() + return +} + +// SetInstanceStatsRandomize safely sets the Configuration value for state's 'InstanceStatsRandomize' field +func (st *ConfigState) SetInstanceStatsRandomize(v bool) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.InstanceStatsRandomize = v + st.reloadToViper() +} + +// InstanceStatsRandomizeFlag returns the flag name for the 'InstanceStatsRandomize' field +func InstanceStatsRandomizeFlag() string { return "instance-stats-randomize" } + +// GetInstanceStatsRandomize safely fetches the value for global configuration 'InstanceStatsRandomize' field +func GetInstanceStatsRandomize() bool { return global.GetInstanceStatsRandomize() } + +// SetInstanceStatsRandomize safely sets the value for global configuration 'InstanceStatsRandomize' field +func SetInstanceStatsRandomize(v bool) { global.SetInstanceStatsRandomize(v) } + // GetAccountsRegistrationOpen safely fetches the Configuration value for state's 'AccountsRegistrationOpen' field func (st *ConfigState) GetAccountsRegistrationOpen() (v bool) { st.mutex.RLock() @@ -2699,7 +2724,7 @@ func (st *ConfigState) SetAdvancedRateLimitExceptionsParsed(v []netip.Prefix) { } // AdvancedRateLimitExceptionsParsedFlag returns the flag name for the 'AdvancedRateLimitExceptionsParsed' field -func AdvancedRateLimitExceptionsParsedFlag() string { return "" } +func AdvancedRateLimitExceptionsParsedFlag() string { return "advanced-rate-limit-exceptions-parsed" } // GetAdvancedRateLimitExceptionsParsed safely fetches the value for global configuration 'AdvancedRateLimitExceptionsParsed' field func GetAdvancedRateLimitExceptionsParsed() []netip.Prefix { diff --git a/internal/processing/fedi/wellknown.go b/internal/processing/fedi/wellknown.go index 4784b4bf7..ac92370c8 100644 --- a/internal/processing/fedi/wellknown.go +++ b/internal/processing/fedi/wellknown.go @@ -65,16 +65,30 @@ func (p *Processor) NodeInfoRelGet(ctx context.Context) (*apimodel.WellKnownResp // NodeInfoGet returns a node info struct in response to a node info request. func (p *Processor) NodeInfoGet(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) { - host := config.GetHost() + var ( + userCount int + postCount int + err error + ) - userCount, err := p.state.DB.CountInstanceUsers(ctx, host) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } + if config.GetInstanceStatsRandomize() { + // Use randomized stats. + stats := p.converter.RandomStats() + userCount = int(stats.TotalUsers) + postCount = int(stats.Statuses) + } else { + // Count actual stats. + host := config.GetHost() - postCount, err := p.state.DB.CountInstanceStatuses(ctx, host) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) + userCount, err = p.state.DB.CountInstanceUsers(ctx, host) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + postCount, err = p.state.DB.CountInstanceStatuses(ctx, host) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } } return &apimodel.Nodeinfo{ diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 311839dc0..4fbe1dfd3 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -18,10 +18,17 @@ package typeutils import ( + crand "crypto/rand" + "math/big" + "math/rand" "sync" + "sync/atomic" + "time" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" ) @@ -31,6 +38,7 @@ type Converter struct { randAvatars sync.Map visFilter *visibility.Filter intFilter *interaction.Filter + randStats atomic.Pointer[apimodel.RandomStats] } func NewConverter(state *state.State) *Converter { @@ -41,3 +49,53 @@ func NewConverter(state *state.State) *Converter { intFilter: interaction.NewFilter(state), } } + +// RandomStats returns or generates +// and returns random instance stats. +func (c *Converter) RandomStats() apimodel.RandomStats { + now := time.Now() + stats := c.randStats.Load() + if stats != nil && time.Since(stats.Generated) < time.Hour { + // Random stats are still + // fresh (less than 1hr old), + // so return them as-is. + return *stats + } + + // Generate new random stats. + newStats := genRandStats() + newStats.Generated = now + c.randStats.Store(&newStats) + return newStats +} + +func genRandStats() apimodel.RandomStats { + const ( + statusesMax = 10000000 + usersMax = 1000000 + ) + + statusesB, err := crand.Int(crand.Reader, big.NewInt(statusesMax)) + if err != nil { + // Only errs if something is buggered with the OS. + log.Panicf(nil, "error randomly generating statuses count: %v", err) + } + + totalUsersB, err := crand.Int(crand.Reader, big.NewInt(usersMax)) + if err != nil { + // Only errs if something is buggered with the OS. + log.Panicf(nil, "error randomly generating users count: %v", err) + } + + // Monthly users should only ever + // be <= 100% of total users. + totalUsers := totalUsersB.Int64() + activeRatio := rand.Float64() //nolint + mau := int64(float64(totalUsers) * activeRatio) + + return apimodel.RandomStats{ + Statuses: statusesB.Int64(), + TotalUsers: totalUsers, + MonthlyActiveUsers: mau, + } +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 71ff71f8b..487e8434e 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1745,6 +1745,12 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins stats["domain_count"] = util.Ptr(domainCount) instance.Stats = stats + if config.GetInstanceStatsRandomize() { + // Whack some random stats on the instance + // to be injected by API handlers. + instance.RandomStats = c.RandomStats() + } + // thumbnail iAccount, err := c.state.DB.GetInstanceAccount(ctx, "") if err != nil { @@ -1821,6 +1827,12 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins instance.Debug = util.Ptr(true) } + if config.GetInstanceStatsRandomize() { + // Whack some random stats on the instance + // to be injected by API handlers. + instance.RandomStats = c.RandomStats() + } + // thumbnail thumbnail := apimodel.InstanceV2Thumbnail{} diff --git a/test/envparsing.sh b/test/envparsing.sh index f9f3f25bc..565ecb1af 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -118,6 +118,7 @@ EXPECT=$(cat << "EOF" "nl", "en-GB" ], + "instance-stats-randomize": true, "instance-subscriptions-process-every": 86400000000000, "instance-subscriptions-process-from": "23:00", "landing-page-user": "admin", @@ -248,6 +249,7 @@ GTS_INSTANCE_FEDERATION_SPAM_FILTER=true \ GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \ GTS_INSTANCE_INJECT_MASTODON_VERSION=true \ GTS_INSTANCE_LANGUAGES="nl,en-gb" \ +GTS_INSTANCE_STATS_RANDOMIZE=true \ GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \ GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \ GTS_ACCOUNTS_REGISTRATION_OPEN=true \