[feature] Add instance-stats-randomize config option (#3718)

* [feature] Add `instance-stats-randomize` config option

* don't use cache (overkill)
This commit is contained in:
tobi 2025-01-31 19:27:18 +01:00 committed by GitHub
parent c47b9bd1d1
commit a55bd6d2bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 183 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?"`

View File

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

View File

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

View File

@ -65,17 +65,31 @@ 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) {
var (
userCount int
postCount int
err error
)
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()
userCount, err := p.state.DB.CountInstanceUsers(ctx, host)
userCount, err = p.state.DB.CountInstanceUsers(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
postCount, err := p.state.DB.CountInstanceStatuses(ctx, host)
postCount, err = p.state.DB.CountInstanceStatuses(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
}
return &apimodel.Nodeinfo{
Version: nodeInfoVersion,

View File

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

View File

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

View File

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