From dd2d49671a480c14f30758d4484fc615461d7eca Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 17:30:54 +0200 Subject: [PATCH 1/8] various fixes: login updates, db hang fixes, make more hardcoded constants settings. --- .env.example | 206 +++++++++++++ AGENTS.md | 9 +- CHANGELOG.md | 64 +++++ CONTRIBUTING.md | 4 +- apps/gamend_core/config/test.exs | 4 + apps/gamend_core/lib/gamend/accounts.ex | 244 ++++++++++++++-- .../lib/gamend/accounts/api_token.ex | 116 ++++++++ .../lib/gamend/accounts/api_tokens.ex | 238 +++++++++++++++ .../lib/gamend/accounts/avatar_mirror.ex | 2 +- .../lib/gamend/accounts/broadcasts.ex | 91 +++--- .../gamend/accounts/confirmation_mailer.ex | 68 +++++ .../lib/gamend/accounts/login_lockout.ex | 36 +++ .../lib/gamend/accounts/login_lockouts.ex | 134 +++++++++ .../lib/gamend/accounts/registration.ex | 70 ++--- .../gamend/accounts/stale_presence_sweeper.ex | 53 +++- apps/gamend_core/lib/gamend/accounts/stats.ex | 12 +- apps/gamend_core/lib/gamend/accounts/user.ex | 8 +- .../lib/gamend/accounts/user_notifier.ex | 38 ++- .../lib/gamend/accounts/user_token.ex | 69 +++-- apps/gamend_core/lib/gamend/after_commit.ex | 116 ++++++++ apps/gamend_core/lib/gamend/async.ex | 18 +- apps/gamend_core/lib/gamend/broadcast.ex | 18 ++ apps/gamend_core/lib/gamend/cache.ex | 28 +- apps/gamend_core/lib/gamend/cache/settings.ex | 21 ++ apps/gamend_core/lib/gamend/chat.ex | 10 +- .../lib/gamend/content/markdown.ex | 20 +- apps/gamend_core/lib/gamend/economy.ex | 3 +- apps/gamend_core/lib/gamend/friends.ex | 24 +- apps/gamend_core/lib/gamend/groups.ex | 91 +++--- apps/gamend_core/lib/gamend/groups/invites.ex | 19 +- .../lib/gamend/groups/join_requests.ex | 8 +- apps/gamend_core/lib/gamend/groups/shared.ex | 48 ++-- apps/gamend_core/lib/gamend/hooks.ex | 70 +++-- .../lib/gamend/hooks/plugin_manager.ex | 51 +++- apps/gamend_core/lib/gamend/http.ex | 54 ++++ apps/gamend_core/lib/gamend/inventory.ex | 3 +- apps/gamend_core/lib/gamend/jobs.ex | 73 +++++ apps/gamend_core/lib/gamend/kv.ex | 44 ++- apps/gamend_core/lib/gamend/leaderboards.ex | 11 +- apps/gamend_core/lib/gamend/ledger.ex | 2 +- apps/gamend_core/lib/gamend/limits.ex | 16 ++ apps/gamend_core/lib/gamend/lobbies.ex | 270 ++++++++++-------- .../gamend_core/lib/gamend/lobby_snapshots.ex | 31 +- apps/gamend_core/lib/gamend/lock.ex | 91 +++++- apps/gamend_core/lib/gamend/lock/local.ex | 10 + apps/gamend_core/lib/gamend/mail.ex | 7 + apps/gamend_core/lib/gamend/matchmaking.ex | 20 +- .../lib/gamend/matchmaking/broadcast.ex | 5 +- apps/gamend_core/lib/gamend/notifications.ex | 16 +- .../gamend_core/lib/gamend/oauth/exchanger.ex | 5 +- .../lib/gamend/oauth/google_id_token.ex | 2 +- apps/gamend_core/lib/gamend/parties.ex | 39 +-- apps/gamend_core/lib/gamend/payments.ex | 21 +- .../lib/gamend/payments/provider_config.ex | 24 ++ .../lib/gamend/payments/providers/apple.ex | 2 +- .../lib/gamend/payments/providers/google.ex | 2 +- .../lib/gamend/payments/providers/steam.ex | 2 +- .../lib/gamend/payments/providers/stripe.ex | 65 ++++- .../lib/gamend/payments/settings.ex | 6 + .../lib/gamend/payments/stripe_events.ex | 55 +++- apps/gamend_core/lib/gamend/push.ex | 4 +- apps/gamend_core/lib/gamend/quests.ex | 18 +- apps/gamend_core/lib/gamend/ready_checks.ex | 13 +- apps/gamend_core/lib/gamend/realtime.ex | 3 +- apps/gamend_core/lib/gamend/repo.ex | 2 +- .../lib/gamend/repo/advisory_lock.ex | 36 ++- apps/gamend_core/lib/gamend/retention.ex | 141 +++++++-- apps/gamend_core/lib/gamend/signaling.ex | 13 +- apps/gamend_core/lib/gamend/signals.ex | 3 +- apps/gamend_core/lib/gamend/storage.ex | 33 ++- apps/gamend_core/lib/gamend/storage/local.ex | 5 +- apps/gamend_core/lib/gamend/storage/s3.ex | 22 +- apps/gamend_core/lib/gamend/tournaments.ex | 55 +--- .../lib/gamend/tournaments/ticker.ex | 16 +- apps/gamend_core/mix.lock | 26 +- .../20260925090000_create_api_tokens.exs | 30 ++ .../20260925130000_create_login_lockouts.exs | 27 ++ ...100_add_deletion_scheduled_at_to_users.exs | 25 ++ .../20260925130200_stable_storage_urls.exs | 57 ++++ .../gamend/accounts/api_token_cap_test.exs | 55 ++++ .../test/gamend/accounts/api_tokens_test.exs | 136 +++++++++ .../gamend/accounts/deletion_grace_test.exs | 59 ++++ .../gamend/accounts/login_lockouts_test.exs | 99 +++++++ .../accounts/token_validity_settings_test.exs | 52 ++++ .../gamend/accounts/user_notifier_test.exs | 61 ++++ .../gamend_core/test/gamend/accounts_test.exs | 40 ++- .../test/gamend/after_commit_test.exs | 165 +++++++++++ .../test/gamend/content/markdown_test.exs | 31 ++ .../test/gamend/hooks_host_modules_test.exs | 45 +++ apps/gamend_core/test/gamend/http_test.exs | 37 +++ .../test/gamend/jobs_oban_config_test.exs | 22 ++ .../test/gamend/lock_hold_test.exs | 172 +++++++++++ .../test/gamend/matchmaking_test.exs | 11 + .../payments/provider_adapters_test.exs | 116 ++++++++ .../test/gamend/retention_test.exs | 18 ++ .../test/gamend/runtime_settings_test.exs | 81 ++++++ .../stable_storage_urls_migration_test.exs | 43 +++ apps/gamend_web/lib/gamend_web/api_spec.ex | 4 +- .../lib/gamend_web/auth/api_token_auth.ex | 70 +++++ .../lib/gamend_web/auth/guardian.ex | 7 +- .../lib/gamend_web/auth/optional_pipeline.ex | 3 + .../lib/gamend_web/auth/pipeline.ex | 4 + apps/gamend_web/lib/gamend_web/auth/tokens.ex | 57 +++- .../gamend_web/channels/signaling_channel.ex | 29 +- .../lib/gamend_web/channels/user_channel.ex | 11 +- .../lib/gamend_web/channels/webrtc_peer.ex | 6 +- .../controllers/api/v1/me_controller.ex | 14 +- .../controllers/api/v1/session_controller.ex | 82 +++--- .../controllers/api/v1/storage_controller.ex | 24 +- .../gamend_web/controllers/auth_controller.ex | 30 +- .../controllers/user_session_controller.ex | 45 ++- apps/gamend_web/lib/gamend_web/endpoint.ex | 71 ++++- .../gamend_web/lib/gamend_web/host_runtime.ex | 44 +-- apps/gamend_web/lib/gamend_web/http.ex | 12 + .../live/admin_live/config_diagnostics.ex | 2 +- .../lib/gamend_web/live/admin_live/lobbies.ex | 3 +- .../live/admin_live/rate_limiting.ex | 8 +- .../lib/gamend_web/live/admin_live/storage.ex | 8 +- .../lib/gamend_web/live/admin_live/users.ex | 82 +++++- .../lib/gamend_web/live/play_live.ex | 2 +- .../gamend_web/live/user_live/registration.ex | 8 +- .../lib/gamend_web/live/user_live/settings.ex | 15 +- .../live/user_live/settings/account_tab.ex | 33 ++- .../live/user_live/settings/api_tokens_tab.ex | 240 ++++++++++++++++ .../live/user_live/settings/payments_tab.ex | 31 +- .../gamend_web/lib/gamend_web/live_helpers.ex | 4 +- .../gamend_web/lib/gamend_web/plugs/ip_ban.ex | 25 +- .../lib/gamend_web/plugs/locale_path.ex | 9 + .../lib/gamend_web/plugs/rate_limiter.ex | 38 ++- apps/gamend_web/lib/gamend_web/rate_limit.ex | 28 ++ .../lib/gamend_web/router/shared.ex | 4 +- .../gamend_web/lib/gamend_web/schemas/auth.ex | 12 +- apps/gamend_web/lib/gamend_web/uploads.ex | 7 +- apps/gamend_web/lib/gamend_web/user_auth.ex | 61 ++-- apps/gamend_web/lib/gamend_web/webrtc.ex | 70 +++++ apps/gamend_web/mix.lock | 28 +- .../priv/gettext/ar/LC_MESSAGES/default.po | 7 +- .../priv/gettext/bg/LC_MESSAGES/default.po | 7 +- .../priv/gettext/cs/LC_MESSAGES/default.po | 7 +- .../priv/gettext/da/LC_MESSAGES/default.po | 7 +- .../priv/gettext/de/LC_MESSAGES/default.po | 7 +- apps/gamend_web/priv/gettext/default.pot | 7 +- .../priv/gettext/el/LC_MESSAGES/default.po | 7 +- .../priv/gettext/en/LC_MESSAGES/default.po | 7 +- .../priv/gettext/es/LC_MESSAGES/default.po | 7 +- .../priv/gettext/fi/LC_MESSAGES/default.po | 7 +- .../priv/gettext/fr/LC_MESSAGES/default.po | 7 +- .../priv/gettext/hu/LC_MESSAGES/default.po | 7 +- .../priv/gettext/id/LC_MESSAGES/default.po | 7 +- .../priv/gettext/it/LC_MESSAGES/default.po | 7 +- .../priv/gettext/ja/LC_MESSAGES/default.po | 7 +- .../priv/gettext/ko/LC_MESSAGES/default.po | 7 +- .../priv/gettext/nl/LC_MESSAGES/default.po | 7 +- .../priv/gettext/no/LC_MESSAGES/default.po | 7 +- .../priv/gettext/pl/LC_MESSAGES/default.po | 7 +- .../priv/gettext/pt/LC_MESSAGES/default.po | 7 +- .../priv/gettext/pt_BR/LC_MESSAGES/default.po | 7 +- .../priv/gettext/ro/LC_MESSAGES/default.po | 7 +- .../priv/gettext/ru/LC_MESSAGES/default.po | 7 +- .../priv/gettext/sv/LC_MESSAGES/default.po | 7 +- .../priv/gettext/th/LC_MESSAGES/default.po | 7 +- .../priv/gettext/tr/LC_MESSAGES/default.po | 7 +- .../priv/gettext/uk/LC_MESSAGES/default.po | 7 +- .../priv/gettext/vi/LC_MESSAGES/default.po | 7 +- .../priv/gettext/zh_CN/LC_MESSAGES/default.po | 7 +- .../priv/gettext/zh_TW/LC_MESSAGES/default.po | 7 +- .../test/gamend/runtime_config_test.exs | 17 ++ .../test/gamend_web/abuse_limits_test.exs | 80 ++++++ .../gamend_web/account_protection_test.exs | 207 ++++++++++++++ .../gamend_web/auth/api_token_auth_test.exs | 78 +++++ .../test/gamend_web/auth/guardian_test.exs | 9 +- .../channels/signaling_channel_test.exs | 25 ++ .../api/v1/hook_controller_test.exs | 13 +- .../api/v1/session_controller_test.exs | 83 +++++- .../live/configurable_pages_test.exs | 110 +++++++ .../live/user_live/registration_test.exs | 12 +- .../user_live/settings_api_tokens_test.exs | 84 ++++++ .../live/user_live/settings_payments_test.exs | 51 ++++ .../test/gamend_web/plugs/host_plugs_test.exs | 60 ++++ .../test/gamend_web/plugs/ip_ban_test.exs | 32 +++ .../test/gamend_web/user_auth_test.exs | 33 +++ clients/cpp_template/include/gamend/auth.hpp | 4 +- .../cpp_template/include/gamend/session.hpp | 2 +- config/dev.exs | 3 +- config/host_config.exs | 8 +- config/test.exs | 7 +- mix.lock | 26 +- modules/plugins/webrtc_lobby_hook/mix.lock | 2 +- priv/docs/10-setup/40-data-schema.md | 4 +- .../20-authentication/10-authentication.md | 49 +++- priv/docs/20-authentication/85-api-tokens.md | 57 ++++ priv/docs/30-clients/20-js-sdk.md | 6 +- priv/docs/30-clients/25-cpp-sdk.md | 5 +- priv/docs/30-clients/50-webrtc.md | 3 +- priv/docs/40-gameplay/02-lobbies.md | 2 +- priv/docs/40-gameplay/20-tournaments.md | 2 + priv/docs/40-gameplay/30-matchmaking.md | 2 + priv/docs/45-storage/20-object-storage.md | 5 +- priv/docs/45-storage/30-data-retention.md | 4 +- priv/docs/60-operations/35-security.md | 8 +- priv/docs/60-operations/40-settings.md | 80 +++++- sdk/lib/gamend/accounts.ex | 181 ++++++++++-- sdk/lib/gamend/hooks.ex | 42 ++- sdk/lib/gamend/kv.ex | 20 ++ sdk/lib/gamend/lock.ex | 45 ++- sdk/lib/gamend/payments.ex | 37 +++ sdk_tools/lib/gamend/gdscript/api.ex | 14 +- 207 files changed, 6582 insertions(+), 1030 deletions(-) create mode 100644 apps/gamend_core/lib/gamend/accounts/api_token.ex create mode 100644 apps/gamend_core/lib/gamend/accounts/api_tokens.ex create mode 100644 apps/gamend_core/lib/gamend/accounts/confirmation_mailer.ex create mode 100644 apps/gamend_core/lib/gamend/accounts/login_lockout.ex create mode 100644 apps/gamend_core/lib/gamend/accounts/login_lockouts.ex create mode 100644 apps/gamend_core/lib/gamend/after_commit.ex create mode 100644 apps/gamend_core/lib/gamend/http.ex create mode 100644 apps/gamend_core/priv/repo/migrations/20260925090000_create_api_tokens.exs create mode 100644 apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs create mode 100644 apps/gamend_core/priv/repo/migrations/20260925130100_add_deletion_scheduled_at_to_users.exs create mode 100644 apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs create mode 100644 apps/gamend_core/test/gamend/accounts/api_token_cap_test.exs create mode 100644 apps/gamend_core/test/gamend/accounts/api_tokens_test.exs create mode 100644 apps/gamend_core/test/gamend/accounts/deletion_grace_test.exs create mode 100644 apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs create mode 100644 apps/gamend_core/test/gamend/accounts/token_validity_settings_test.exs create mode 100644 apps/gamend_core/test/gamend/accounts/user_notifier_test.exs create mode 100644 apps/gamend_core/test/gamend/after_commit_test.exs create mode 100644 apps/gamend_core/test/gamend/hooks_host_modules_test.exs create mode 100644 apps/gamend_core/test/gamend/http_test.exs create mode 100644 apps/gamend_core/test/gamend/lock_hold_test.exs create mode 100644 apps/gamend_core/test/gamend/runtime_settings_test.exs create mode 100644 apps/gamend_core/test/gamend/stable_storage_urls_migration_test.exs create mode 100644 apps/gamend_web/lib/gamend_web/auth/api_token_auth.ex create mode 100644 apps/gamend_web/lib/gamend_web/live/user_live/settings/api_tokens_tab.ex create mode 100644 apps/gamend_web/lib/gamend_web/webrtc.ex create mode 100644 apps/gamend_web/test/gamend_web/account_protection_test.exs create mode 100644 apps/gamend_web/test/gamend_web/auth/api_token_auth_test.exs create mode 100644 apps/gamend_web/test/gamend_web/live/configurable_pages_test.exs create mode 100644 apps/gamend_web/test/gamend_web/live/user_live/settings_api_tokens_test.exs create mode 100644 apps/gamend_web/test/gamend_web/plugs/host_plugs_test.exs create mode 100644 priv/docs/20-authentication/85-api-tokens.md diff --git a/.env.example b/.env.example index 6625cec54..ddfb7a9e8 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,20 @@ # Authentication # ────────────────────────────────────────────────────────────────────────── +# Lifetime of API access tokens, in minutes. Login and refresh answer it as +# expires_in. Applies to tokens issued after the change. +# GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES=15 + # Allow device-only accounts to upload an avatar. Off by default: an # anonymous account costs one request to create, so this is the cheapest way # for a bot to burn object storage. # GAMEND_AUTH_ANONYMOUS_CAN_UPLOAD_AVATAR=false +# Longest lifetime a personal API token may have, in days. Applies to +# existing tokens too, counted from creation. 0 allows tokens that never +# expire. +# GAMEND_AUTH_API_TOKEN_MAX_DAYS=0 + # Argon2id memory per hash, as a power of two in KiB — 14 is 16 MiB. Peak # use is this times the vCPU count, not times the request rate, because the # BEAM runs at most one hash per dirty CPU scheduler. Below 12 (4 MiB) it @@ -32,6 +41,17 @@ # Argon2id passes over memory. Raise to compensate when lowering memory. # GAMEND_AUTH_ARGON2_TIME_COST=3 +# How long the link confirming a new email address stays valid, in days. +# GAMEND_AUTH_CHANGE_EMAIL_DAYS=7 + +# How long an email confirmation link stays valid, in days. +# GAMEND_AUTH_CONFIRM_EMAIL_DAYS=7 + +# Days between a player deleting their own account and it being deleted. +# Signing in on the website within that time keeps the account. 0 deletes at +# once. +# GAMEND_AUTH_DELETION_GRACE_DAYS=0 + # Allow POST /api/v1/login/device. When on, any unknown device_id creates an # anonymous account. # GAMEND_AUTH_DEVICE_AUTH_ENABLED=true @@ -40,9 +60,28 @@ # Secret — never commit a real value. # GAMEND_AUTH_GUARDIAN_SECRET_KEY= +# Failed passwords for one email address that lock its password sign-in. +# Counted per address across every IP. 0 disables the lockout. +# GAMEND_AUTH_LOCKOUT_ATTEMPTS=10 + +# How long a lock lasts. Emailed login links and provider sign-in still work +# meanwhile, so the owner is never shut out. +# GAMEND_AUTH_LOCKOUT_MINUTES=15 + +# The failures must fall within this many minutes to lock. +# GAMEND_AUTH_LOCKOUT_WINDOW_MINUTES=15 + +# How long an emailed login link stays valid, in minutes. Capped at 60: +# anyone who can read the email can sign in while the link lives. +# GAMEND_AUTH_MAGIC_LINK_MINUTES=15 + # Minimum password length enforced at registration and change. # GAMEND_AUTH_MIN_PASSWORD_LENGTH=8 +# Lifetime of API refresh tokens, in days. A refresh keeps its token, so +# this is how long a client stays signed in without logging in again. +# GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS=30 + # New accounts cannot log in until an admin activates them (beta mode). # GAMEND_AUTH_REQUIRE_ACTIVATION=false @@ -51,6 +90,38 @@ # Secret — never commit a real value. # GAMEND_AUTH_SECRET_KEY_BASE= +# Lifetime of a browser session and its remember-me cookie, in days. An +# active session is renewed once it is half this old. +# GAMEND_AUTH_SESSION_DAYS=14 + +# How recently a user must have signed in to open the settings that change +# their password or email. Submitting the form is allowed 10 minutes more. +# GAMEND_AUTH_SUDO_MODE_MINUTES=10 + +# ────────────────────────────────────────────────────────────────────────── +# Background jobs +# ────────────────────────────────────────────────────────────────────────── + +# Days finished, cancelled and discarded jobs are kept before they are +# deleted. +# GAMEND_JOBS_PRUNE_AFTER_DAYS=7 + +# Per-node concurrent jobs on the default queue. +# GAMEND_JOBS_QUEUE_DEFAULT=10 + +# Per-node concurrent jobs on the hooks queue: enqueued and scheduled plugin +# hooks. +# GAMEND_JOBS_QUEUE_HOOKS=20 + +# Per-node concurrent email sends. +# GAMEND_JOBS_QUEUE_MAILERS=5 + +# Per-node concurrent storage jobs, such as avatar mirroring. +# GAMEND_JOBS_QUEUE_STORAGE=5 + +# Per-node concurrent outgoing webhook deliveries. +# GAMEND_JOBS_QUEUE_WEBHOOKS=10 + # ────────────────────────────────────────────────────────────────────────── # Cache # ────────────────────────────────────────────────────────────────────────── @@ -62,6 +133,13 @@ # clustering. # GAMEND_CACHE_L2=partitioned +# Most entries each node's local cache holds. +# GAMEND_CACHE_MAX_ENTRIES=1000000 + +# Most memory each node's local cache may use, in MB. Lower it on a small +# machine: the default alone is most of a 512 MB instance. +# GAMEND_CACHE_MAX_MEMORY_MB=500 + # single (L1 local only) or multi (L1 + a shared L2). # GAMEND_CACHE_MODE=single @@ -71,6 +149,11 @@ # REQUIRED in production — the server refuses to start without it, when cache.mode is :multi and cache.l2 is :redis. # GAMEND_CACHE_REDIS_URL= +# How long a cached entity (user, lobby, party, group, KV entry...) is kept, +# in ms. On a cluster it bounds how stale a node can be when an invalidation +# is missed. +# GAMEND_CACHE_TTL_MS=60000 + # ────────────────────────────────────────────────────────────────────────── # Captcha # ────────────────────────────────────────────────────────────────────────── @@ -205,6 +288,10 @@ # Email # ────────────────────────────────────────────────────────────────────────── +# Longest one email send may take before it is abandoned. gen_smtp itself +# waits up to 20 minutes for each reply from the relay. +# GAMEND_MAIL_SEND_TIMEOUT_MS=30000 + # GAMEND_MAIL_SMTP_FROM_EMAIL= # GAMEND_MAIL_SMTP_FROM_NAME=Gamend @@ -231,6 +318,21 @@ # Expected in production — logs a warning when missing, if any of smtp_password/smtp_relay/smtp_username is set. # GAMEND_MAIL_SMTP_USERNAME= +# ────────────────────────────────────────────────────────────────────────── +# Hooks +# ────────────────────────────────────────────────────────────────────────── + +# The same, for a hook called inside a database transaction: on SQLite that +# transaction holds the only write connection while the hook runs. +# GAMEND_HOOKS_CALL_TIMEOUT_IN_TRANSACTION_MS=5000 + +# How long a plugin hook or RPC may run before it is killed, in ms. The +# caller's request waits that long. +# GAMEND_HOOKS_CALL_TIMEOUT_MS=60000 + +# Log a hook call as slow when it takes longer than this, in ms. +# GAMEND_HOOKS_SLOW_THRESHOLD_MS=200 + # ────────────────────────────────────────────────────────────────────────── # IndexNow # ────────────────────────────────────────────────────────────────────────── @@ -255,6 +357,13 @@ # Limits # ────────────────────────────────────────────────────────────────────────── +# Largest match a ticket forms when it does not say. At most +# max_matchmaking_players. +# GAMEND_LIMITS_MATCHMAKING_DEFAULT_MAX_PLAYERS=5 + +# Smallest match a ticket forms when it does not say. +# GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS=2 + # Grace before an offline player's ticket is pruned; long enough that a # brief disconnect keeps its queue position. # GAMEND_LIMITS_MATCHMAKING_OFFLINE_GRACE_MS=300000 @@ -269,6 +378,9 @@ # ignored. # GAMEND_LIMITS_MAX_ACTIVE_QUESTS_PER_USER=200 +# Personal API tokens one user may hold, revoked ones not counted. +# GAMEND_LIMITS_MAX_API_TOKENS_PER_USER=10 + # GAMEND_LIMITS_MAX_CHAT_CONTENT=4096 # GAMEND_LIMITS_MAX_CHAT_FILTER_WORD_LEN=64 @@ -606,6 +718,11 @@ # GAMEND_PAYMENTS_STRIPE_API_VERSION=2022-11-15 +# Sell through Stripe Managed Payments (Stripe is merchant of record: it +# charges and remits the buyer's VAT). Accept the terms and set a tax code +# on every product in the Stripe Dashboard first. +# GAMEND_PAYMENTS_STRIPE_MANAGED_PAYMENTS=false + # sk_live_... key, used when environment is production. # Expected in production — logs a warning when missing, when payments.environment is :production. # Secret — never commit a real value. @@ -621,6 +738,18 @@ # Secret — never commit a real value. # GAMEND_PAYMENTS_STRIPE_SANDBOX_WEBHOOK_SECRET= +# ────────────────────────────────────────────────────────────────────────── +# Presence +# ────────────────────────────────────────────────────────────────────────── + +# How often users still marked online after a crash are looked for, in ms. +# GAMEND_PRESENCE_INTERVAL_MS=120000 + +# Mark a user offline once their last_seen_at is this many seconds old. +# Connected sockets refresh it at three fifths of this, at most every 3 +# minutes. +# GAMEND_PRESENCE_STALE_THRESHOLD_S=300 + # ────────────────────────────────────────────────────────────────────────── # Public features # ────────────────────────────────────────────────────────────────────────── @@ -760,6 +889,19 @@ # REQUIRED in production — the server refuses to start without it, when ratelimit.backend is :redis. # GAMEND_RATELIMIT_REDIS_URL= +# Max ICE candidates relayed over the signaling channel per window, per +# user. +# GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT=150 + +# Signaling ICE window, in milliseconds. +# GAMEND_RATELIMIT_SIGNALING_ICE_WINDOW_MS=30000 + +# Max signaling channel messages per window, per user. +# GAMEND_RATELIMIT_SIGNALING_WS_LIMIT=300 + +# Signaling channel window, in milliseconds. +# GAMEND_RATELIMIT_SIGNALING_WS_WINDOW_MS=10000 + # Max WebSocket channel messages per window, per user. # GAMEND_RATELIMIT_WS_LIMIT=60 @@ -806,6 +948,10 @@ # that actually needs a sweep. # GAMEND_RETENTION_ANONYMOUS_USERS_DAYS=90 +# Rows deleted per statement. Lower it if a sweep stalls gameplay writes on +# SQLite, where each statement holds the write lock. +# GAMEND_RETENTION_BATCH_SIZE=500 + # Delete chat messages older than N days. 0 keeps forever. # GAMEND_RETENTION_CHAT_MESSAGES_DAYS=0 @@ -818,12 +964,21 @@ # deletes with no warning. Accounts with no email address cannot be warned. # GAMEND_RETENTION_INACTIVE_USERS_WARN_DAYS=30 +# Hours between full retention sweeps. The first runs five minutes after +# boot. +# GAMEND_RETENTION_INTERVAL_HOURS=6 + # Delete resolved invites and join requests N days after resolution. # GAMEND_RETENTION_INVITES_DAYS=30 # Delete wallet/inventory ledger entries older than N days. 0 keeps forever. # GAMEND_RETENTION_LEDGER_DAYS=0 +# Seconds between sweeps of the classes that free live state: offline lobby +# and party seats, abandoned parties, abandoned lobbies. 0 leaves them to +# the full sweep. +# GAMEND_RETENTION_LIVE_INTERVAL_SECONDS=60 + # Delete lobby snapshots, events and blobs older than N days. # GAMEND_RETENTION_LOBBY_SNAPSHOTS_DAYS=30 @@ -870,9 +1025,23 @@ # an entry with `regex:` for a pattern. # GAMEND_HTTP_ALLOWED_ORIGINS= +# Retries of a failed GET to a provider. POSTs are never retried. 0 +# disables. +# GAMEND_HTTP_CLIENT_RETRIES=1 + +# Per-try timeout for calls to payment, OAuth and avatar providers, in +# milliseconds: connecting, and waiting for the response. +# GAMEND_HTTP_CLIENT_TIMEOUT_MS=10000 + # Public hostname, used to build URLs and OAuth redirect URIs. # GAMEND_HTTP_HOST=localhost +# Largest request body the server reads (JSON, form or multipart), in bytes. +# Raise it with any GAMEND_LIMITS_* size above 1 MB, or requests that size +# are refused first. Local-backend uploads are capped by +# GAMEND_LIMITS_MAX_UPLOAD_BYTES instead. +# GAMEND_HTTP_MAX_BODY_BYTES=1048576 + # TCP port the HTTP listener binds. # GAMEND_HTTP_PORT=4000 @@ -914,6 +1083,14 @@ # Secret — never commit a real value. # GAMEND_STORAGE_SECRET_ACCESS_KEY= +# Lifetime of the signed link /storage/ redirects to, for an S3 bucket +# with no public_url. S3 caps it at 604800 (7 days). +# GAMEND_STORAGE_SIGNED_URL_SECONDS=3600 + +# How long an upload ticket stays valid, in seconds. Raise it for large +# uploads over slow connections. +# GAMEND_STORAGE_UPLOAD_TTL_SECONDS=600 + # ────────────────────────────────────────────────────────────────────────── # TLS & certificates # ────────────────────────────────────────────────────────────────────────── @@ -938,3 +1115,32 @@ # HTTPS listen port. # GAMEND_TLS_PORT=443 + +# ────────────────────────────────────────────────────────────────────────── +# Tournaments +# ────────────────────────────────────────────────────────────────────────── + +# Seconds between tournament ticks: state transitions, match-ready, deadline +# sweeps and recurrence. A round can start or time out up to this late. +# GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS=30 + +# ────────────────────────────────────────────────────────────────────────── +# WebRTC +# ────────────────────────────────────────────────────────────────────────── + +# Comma-separated STUN server URLs. Empty uses none. +# GAMEND_WEBRTC_STUN_URLS=stun:stun.l.google.com:19302 + +# Credential for the TURN servers. +# Expected in production — logs a warning when missing, if any of turn_username is set. +# Secret — never commit a real value. +# GAMEND_WEBRTC_TURN_CREDENTIAL= + +# Comma-separated TURN server URLs (turn:host:3478, turns:host:5349). Empty +# uses none. Only needed when the server itself is behind NAT or UDP is +# filtered. +# GAMEND_WEBRTC_TURN_URLS= + +# Username for the TURN servers. +# Expected in production — logs a warning when missing, if any of turn_credential is set. +# GAMEND_WEBRTC_TURN_USERNAME= diff --git a/AGENTS.md b/AGENTS.md index 2dad268b5..fd91fb9ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ invariants code must keep. | `Gamend.Push` | [push](priv/docs/40-gameplay/80-push-notifications.md) | Delivery per token (FCM / APNs) on the Oban `push` queue. No public send endpoint | | `Gamend.Quests` | [quests](priv/docs/40-gameplay/40-quests.md) | Achievements are quests with `category: "achievement"`; there is no Achievements context. Progress is server-authoritative, rewards pay exactly once | | `Gamend.Leaderboards` | [leaderboards](priv/docs/40-gameplay/10-leaderboards.md) | | -| `Gamend.Tournaments` | [tournaments](priv/docs/40-gameplay/20-tournaments.md) | Hooks and broadcasts are queued and flushed after commit (`defer/1`) | +| `Gamend.Tournaments` | [tournaments](priv/docs/40-gameplay/20-tournaments.md) | Hooks and broadcasts wait for the commit (`Gamend.AfterCommit`); `tick/1` locks with `Lock.exclusive/3`, one transaction per tournament | | `Gamend.Matchmaking`, `Gamend.ReadyChecks` | [matchmaking](priv/docs/40-gameplay/30-matchmaking.md) | A party queues as one unit | | `Gamend.Economy`, `Gamend.Inventory` | [economy](priv/docs/50-monetization/05-economy.md) | Ledgered through `Gamend.Ledger`. `Economy.spend/4` is one conditional SQL statement and needs no lock | | `Gamend.Payments` | [payments](priv/docs/50-monetization/10-payments.md) | | @@ -226,7 +226,7 @@ Web-side features with no context: the site search palette (`GamendWeb.SearchInd - Plugins implement `Gamend.Hooks`. They load from `modules/plugins/*` (`GAMEND_CONTENT_PLUGINS_DIR`) as bundled `ebin/`; run `mix plugin.bundle` after changing one. Examples live in `modules/plugins_examples/`. - `before_*` hooks are pipelines: return `{:ok, value}` to allow (optionally modified) or `{:error, reason}` to block. `after_*` hooks run asynchronously via `Gamend.Async.run/1`. -- **Never** dispatch a hook or broadcast inside a transaction or lock. +- **Never** dispatch a hook or broadcast inside a transaction or lock. Open transactions with `Gamend.AfterCommit.transaction/2` and broadcast with `Gamend.Broadcast.publish/2`, which wait for the commit; run a `before_*` hook before taking the lock. See [CONTRIBUTING.md](CONTRIBUTING.md#hooks-so-plugins-can-extend-the-feature). - Adding a callback touches six places: [CONTRIBUTING.md](CONTRIBUTING.md#hooks-so-plugins-can-extend-the-feature). The full hook list is in the [server scripting guide](priv/docs/40-gameplay/90-server-scripting.md). ### PubSub & realtime @@ -240,12 +240,13 @@ Web-side features with no context: the site search palette (`GamendWeb.SearchInd ### Caching conventions - App cache is `Gamend.Cache` (Nebulex 3, multilevel: local L1 + optional Redis/partitioned L2). **Nebulex 3 returns `{:ok, value}` tuples** — use `Gamend.Cache.get!/1` (raw value, `nil` on miss), `fetch/1` or `cached/3`, never bare `get/1` compared against raw values. -- Read caching uses **version keys**: cache keys embed a `*_cache_version(...)` counter read via `get!(...) || 1`; invalidate with `Gamend.Cache.bump_version/1`, which also bumps the counter on other nodes. Data entries must carry a TTL (typically 60s) — that TTL is the cross-instance staleness bound. +- Read caching uses **version keys**: cache keys embed a `*_cache_version(...)` counter read via `get!(...) || 1`; invalidate with `Gamend.Cache.bump_version/1`, which also bumps the counter on other nodes. Data entries must carry a TTL, normally `Gamend.Cache.ttl/0` (`GAMEND_CACHE_TTL_MS`, default 60s) — that TTL is the cross-instance staleness bound. - When a stale read would be *incorrect* (not merely briefly outdated) — cached users gating auth, sessions, tokens, KV values — invalidate with `Gamend.Cache.invalidate/1` (delete + PubSub broadcast; `Gamend.Cache.Sync` evicts the key from other instances' L1) instead of `delete/1`. ### Locks -- Any read-modify-write (capacity check before insert, merging a map) runs under `Gamend.Lock.serialize/3`. It uses `pg_advisory_xact_lock` on Postgres and a `:global` mutex (`Gamend.Lock.Local`) on SQLite, so it holds on both. +- Any read-modify-write (capacity check before insert, merging a map) runs under `Gamend.Lock.serialize/3`. It uses `pg_advisory_xact_lock` on Postgres (behind a node-local mutex, so waiters hold no connection) and a `:global` mutex (`Gamend.Lock.Local`) on SQLite, so it holds on both. +- `serialize/3` holds a transaction for its whole function, which on SQLite is the only write lock: keep the function to database work. Hooks, hashing and HTTP calls go before it; broadcasts and tasks inside wait for the commit on their own. A job that must run once cluster-wide but writes in pieces takes `Gamend.Lock.exclusive/3` instead. - Atom namespaces are registered in `@namespaces` in `Gamend.Repo.AdvisoryLock` (`:lobby` 1, `:group` 2, `:party` 3, `:friendship` 4, and so on through 12). Register a new one there; a string namespace needs no registration. - Prefer an atomic write where one exists (`Economy.spend/4`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 665682acb..34ac29ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # September 2026 +- [added] **Per-account lockout after failed passwords.** The per-IP auth limit did nothing against guesses at one account spread across many addresses. Failed passwords are now also counted per email address: `GAMEND_AUTH_LOCKOUT_ATTEMPTS` (default 10, `0` disables) within `GAMEND_AUTH_LOCKOUT_WINDOW_MINUTES` (15) lock password sign-in for that address for `GAMEND_AUTH_LOCKOUT_MINUTES` (15). While locked the password is not checked; `POST /api/v1/login` answers `429 account_locked` with `Retry-After`, and the browser form says so. The count is keyed by a SHA-256 of the address, not by account, so an unregistered address locks the same way and the lock reveals nothing; a correct password clears it. Emailed login links and provider sign-ins still work, so nobody can lock a player out. New table `login_lockouts`, pruned by the retention class of the same name; `Accounts.authenticate_by_password/2` says why a sign-in failed, and `get_user_by_email_and_password/2` goes through it. Admin → Users shows a lock with **Unlock**. + +- [added] **A grace period before a deleted account is gone.** With `GAMEND_AUTH_DELETION_GRACE_DAYS` set (default `0`, delete at once), `DELETE /api/v1/me` and **Delete account** schedule the deletion that many days out (`users.deletion_scheduled_at`) and sign the account out everywhere. Until then API sign-ins answer `403 deletion_scheduled`, so a game client signing in on its own cannot undo it; signing in on the website keeps the account, and so does **Keep account** in Admin → Users, where a scheduled account is marked **Deleting**. The retention class `scheduled_deletions` deletes it on the day through `Accounts.delete_user/1`. Admin deletions and inactivity sweeps never wait. `Accounts.request_deletion/1`, `cancel_deletion/1`, `deletion_scheduled?/1`; every API sign-in now asks `GamendWeb.Auth.Tokens.refusal/1`. + +- [fixed] **Abandoned lobbies and seats are released within a minute of their window.** `GAMEND_RETENTION_ABANDONED_LOBBY_MINUTES` and `_PARTY_MINUTES` say 15 minutes, but the sweep that applies them ran every six hours, so a disconnected player could sit on `already_in_lobby` for up to 6h15m. The classes that free live state (offline lobby and party seats, abandoned parties, abandoned lobbies) now also run every `GAMEND_RETENTION_LIVE_INTERVAL_SECONDS` (default 60, `0` leaves them to the full sweep). The full sweep's cadence and batch size are settings too: `GAMEND_RETENTION_INTERVAL_HOURS` (6) and `GAMEND_RETENTION_BATCH_SIZE` (500). + +- [fixed] **Avatars and icons on a private S3 bucket stopped loading after an hour.** With no `GAMEND_STORAGE_PUBLIC_URL`, `Storage.url/1` answered a link signed for an hour, and that is what uploads saved as `profile_url` and `icon_url`. It now answers `/storage/`, which redirects to a freshly signed link (`GAMEND_STORAGE_SIGNED_URL_SECONDS`, default 3600, cached for half of it). `Storage.url(key, signed: true)` gives the signed link itself, for display only. A migration rewrites the signed links already stored in `users`, `groups`, `quests`, `leaderboards` and `tournaments`. + +- [fixed] **An upload ticket said 600 seconds and accepted 900.** Both are now `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default 600), on local and S3 tickets alike. + +- [fixed] **A slow payment or OAuth provider held a request open for about a minute.** Receipt checks (Apple, Google Play, Steam), OAuth code exchanges, Google ID-token checks and avatar mirroring used Req's defaults: 15 seconds a try, and three retries for a GET. They now go through `Gamend.HTTP`: `GAMEND_HTTP_CLIENT_TIMEOUT_MS` (default 10000) a try and `GAMEND_HTTP_CLIENT_RETRIES` (default 1) retries of a GET. + +- [added] **Settings for values that were fixed in code.** Each keeps its old value by default. + - Browser auth: `GAMEND_AUTH_SESSION_DAYS` (14; the remember-me cookie follows it, and a session renews at half of it), `GAMEND_AUTH_MAGIC_LINK_MINUTES` (15, at most 60), `GAMEND_AUTH_CONFIRM_EMAIL_DAYS` (7), `GAMEND_AUTH_CHANGE_EMAIL_DAYS` (7), `GAMEND_AUTH_SUDO_MODE_MINUTES` (10; submitting the form gets ten minutes more, the 20 `sudo_mode?/1` always allowed). + - `GAMEND_AUTH_API_TOKEN_MAX_DAYS` (0, no cap): caps a personal API token's lifetime, removes "never", and ends older tokens that many days after their creation. + - Background jobs: `GAMEND_JOBS_QUEUE_DEFAULT`, `_HOOKS`, `_MAILERS`, `_STORAGE`, `_WEBHOOKS` (10, 20, 5, 5, 10) and `GAMEND_JOBS_PRUNE_AFTER_DAYS` (7), applied by `Jobs.oban_config/0` over the compiled Oban config. `GAMEND_PUSH_QUEUE_CONCURRENCY` moved there from `HostRuntime`. + - Cache: `GAMEND_CACHE_MAX_ENTRIES` (1000000) and `GAMEND_CACHE_MAX_MEMORY_MB` (500) per node, and `GAMEND_CACHE_TTL_MS` (60000) for every entity cache, read through `Gamend.Cache.ttl/0`. + - Hooks: `GAMEND_HOOKS_CALL_TIMEOUT_MS` (60000), `GAMEND_HOOKS_CALL_TIMEOUT_IN_TRANSACTION_MS` (5000), `GAMEND_HOOKS_SLOW_THRESHOLD_MS` (200). + - Presence: `GAMEND_PRESENCE_INTERVAL_MS` (120000) and `GAMEND_PRESENCE_STALE_THRESHOLD_S` (300). A connected socket's heartbeat follows the threshold (three fifths of it, at most every 3 minutes, as before). + - `GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS` (30). + - WebRTC: `GAMEND_WEBRTC_STUN_URLS` and a TURN relay with `GAMEND_WEBRTC_TURN_URLS`, `_TURN_USERNAME`, `_TURN_CREDENTIAL`, for the server's own peer. + - `GAMEND_HTTP_MAX_BODY_BYTES` (1048576): the largest request body parsed. `Plug.Parsers` is built at runtime for it. + - Peer-to-peer signaling rate limits: `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT` / `_WINDOW_MS` (300 / 10s) and `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT` / `_WINDOW_MS` (150 / 30s). The admin rate-limiting page reads them. + - `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS` (2) and `_MAX_PLAYERS` (5), for a ticket that leaves its size out. + +- [changed] **Config keys replaced by those settings.** The undeclared app-env keys `:hooks_call_timeout`, `:hooks_call_timeout_in_transaction` and `:slow_hook_threshold_ms` under `:gamend_core` are gone: set `call_timeout_ms`, `call_timeout_in_transaction_ms` and `slow_threshold_ms` on `Gamend.Hooks.PluginManager`. The signaling keys `:signaling_ws_window` and `:signaling_ice_window` are now `:signaling_ws_window_ms` and `:signaling_ice_window_ms`. `config/host_config.exs` no longer sets `ice_servers` for `:webrtc`; a host that sets it still overrides the WebRTC settings. The websocket's idle timeout and frame size stay compile-time: Phoenix fixes a socket's options when the endpoint compiles. + +- [added] **Token lifetimes are settings.** `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES` (default 15) and `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS` (default 30) set how long API access and refresh tokens last. Both were literals: the access TTL in the Guardian config (in `HostRuntime` for prod, and again in `config/dev.exs` and `config/test.exs`), the refresh TTL at every call that signed one, and `expires_in: 900` in the login, refresh and OAuth answers. `GamendWeb.Auth.Guardian` now takes its `token_ttl` from `GamendWeb.Auth.Tokens.ttls/0`, so every token signed, anywhere, follows the settings, and `expires_in` is derived from the same value. A value below 1 counts as 1. The `ttl` key is gone from the Guardian config; a host that still sets one is ignored, because the per-type TTL wins over it. Tokens already issued keep the lifetime they were signed with. The Godot and C++ SDKs schedule their refresh from `expires_in` and need no change. + +- [changed] **Lobby passwords are hashed with Argon2id.** A join attempt against a password-protected lobby spent ~250ms of CPU on bcrypt at cost 12, and a join is something any signed-in player can repeat. New lobby passwords are hashed with `Gamend.Accounts.PasswordHash`, as account passwords are (~24ms); existing bcrypt hashes still verify. + +- [changed] **IPv6 bans cover the /64.** `GamendWeb.Plugs.IpBan.ban/2` stores an IPv6 address under its /64 (`2001:db8::1` is listed as `2001:db8::/64`), since a subscriber can move to another address inside the /64. An IPv4 ban is unchanged. Bans stored per IPv6 address before this still match and still lift. + +- [fixed] **The rate limiter runs before the request body is parsed.** It came after `Plug.Parsers`, so a request it was about to refuse had its body (up to 1 MB of JSON or multipart) read and decoded first. It now runs right after the IP ban, with CORS ahead of it so a 429 still carries the headers a web client needs to read it. It strips a locale prefix itself, since it now runs before `LocalePath`. + +- [fixed] **An email send gives up after 30 seconds.** gen_smtp waits up to 20 minutes for each reply from the relay, and that is not configurable, so a hung relay hung the magic-link request, email change or mail job that was sending. `UserNotifier` now runs the send in a task limited by the new `GAMEND_MAIL_SEND_TIMEOUT_MS` (default 30000). + +- [fixed] **A cache entry invalidated inside a transaction is invalidated again after the commit.** Until the commit, a concurrent read still sees the old row and could cache it back, on any node, where it stayed until the TTL. `Gamend.Cache.invalidate/1` and `bump_version/1` evict immediately, so the transaction reads its own writes, and once more after the commit. + +- [fixed] **The tournament tick holds no transaction on Postgres either.** `Lock.exclusive/3` takes a session-level advisory lock on one connection and runs the tick on it, so each tournament commits on its own and releases its rows at once. Before, one transaction held every row the tick touched until it finished. If the process dies, the connection closes and the lock goes with it. + +- [fixed] **The plugin SDK had no default for `before_group_join/3`.** Its `__using__` defined every callback but that one, against the rule for SDK callbacks. A plugin fell through to core's default anyway, which returns the same value. The injected defaults are now split across three quoted blocks, `default_callbacks`, `more_default_callbacks` and `overridable_callbacks`, one block of that length being more than credo allows. + +- [added] **Stripe Managed Payments.** `GAMEND_PAYMENTS_STRIPE_MANAGED_PAYMENTS=true` (default off) makes Stripe the merchant of record: every Checkout Session carries `managed_payments[enabled]`, so Stripe charges and remits the buyer's VAT or sales tax, handles disputes and sends the receipts (from Link). The Checkout Session call is raised to API version `2025-03-31.basil` when the configured `GAMEND_PAYMENTS_STRIPE_API_VERSION` is older, as Managed Payments requires; every other call keeps the configured version (`ProviderConfig.stripe_checkout_api_version/0`). None of the parameters Managed Payments rejects is sent by core. Before switching it on: accept the Managed Payments terms and give every product an eligible tax code in the Stripe Dashboard. + +- [fixed] **Slow work no longer runs while a transaction holds the database.** On SQLite the repo has one connection and every transaction takes the write lock, so whatever runs inside a transaction runs while every other request waits. Several paths did slow work there. Joining a password-protected lobby ran the password check (bcrypt, ~250ms) and the `before_lobby_join` hook inside the lobby's lock, so one player sending wrong passwords could stall the whole server. Group join, lobby metadata merges (`Lobbies.merge_metadata/2`) and the payment metadata written on entitlement changes ran their `before_*` hook inside the lock. Lobby deletion gathered its snapshot and deleted KV entries one statement at a time. The tournament tick held one transaction across every active tournament. Broadcasts, hook tasks and notifications fired from inside transactions all over core, before the write was visible and even when it then rolled back. Now: + - `Gamend.AfterCommit` holds effects until the commit and drops them on rollback. Every transaction in core opens through it, and `Gamend.Lock.serialize/3` uses it too. + - `Gamend.Broadcast.publish/2` and `Gamend.Async.run/1` wait for the commit when called inside a transaction, and replace the direct `Phoenix.PubSub.broadcast` calls in core. A test fails on a bare `Repo.transaction` or `Phoenix.PubSub.broadcast` in core. + - `before_*` hooks and password checks run before the lock, which then re-checks only what a concurrent writer could change. A full lobby or group is still refused without calling the hook. + - Merges that need the hook's answer on the value the lock protects are optimistic: they write only if the value is unchanged and retry otherwise. They also stopped reading through the cache, which could lose a concurrent merge. + - The tournament tick takes the new `Lock.exclusive/3`, so each tournament commits on its own. This also fixes effects queued in a tick that raised: they no longer fire on the next tick. + - On Postgres, `serialize/3` waits on a node-local mutex before taking a connection. Previously one slow holder and nine waiters on the same lobby emptied a pool of ten. + +- [added] **Stripe's customer portal, from account settings.** The Payments tab of `/users/settings` shows **Manage billing** to an account that has paid through Stripe; it opens Stripe's hosted portal (`Stripe.BillingPortal.Session`), where the buyer cancels, changes card and downloads invoices, and returns to the tab. `Payments.stripe_customer_id/1` finds the account's customer on its newest Stripe purchase (the stored checkout session, or subscription); `Payments.create_stripe_billing_portal/2` opens the session. Checkout now keeps one customer per account: a returning buyer's checkout reuses their `cus_` id (set server-side, never taken from the client), and a one-off payment asks Stripe to create one (`customer_creation: "always"`), which subscription mode already did — without it a one-off buyer had no customer and so no portal and no receipts in it. Configure the portal once in the Stripe dashboard (Settings → Billing → Customer portal) before using it in live mode. + +- [added] **Host hook modules.** `config :gamend_core, :host_hook_modules, [MyApp.Hooks]` adds a host app's modules to the lifecycle hooks, after `:hooks_module` and before plugins. A host that needs one event, `after_user_deleted` say, no longer has to take over `:hooks_module`, which made its module the primary for every fan-out hook in place of `Gamend.Hooks.Default`. A host module exports what it implements and is called only for that; `Gamend.Hooks.call/3` still reaches `:hooks_module` alone. +- [added] **Host plugs, ahead of the site.** `config :gamend_web, :host_plugs, [MyApp.GamesHost, {MyApp.Other, opts}]` runs a host app's own plugs right after `ForceSSL`, before the canonical-host redirect, the security headers, static files, the session and the trailing-slash redirect. A second host name the app answers — a game CDN, a status page — no longer has to fight every one of those: a halted conn ends the request there. Each plug is `init/1`ed once per configuration and cached in `:persistent_term`. Unset, nothing changes. + +- [changed] **Sign-up no longer waits on the mail server.** `POST /api/v1/register` and the browser form sent the confirmation email over SMTP inside the transaction that inserted the user. On SQLite the repo has a single connection, so for the length of every SMTP session, often a second or two, no other request could read or write, and a burst of sign-ups (10 a minute per IP) could stall the whole server. The email is now a job on the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), enqueued in that transaction, so a committed account always has its email queued and the transaction holds the database for two inserts. The job mints the token itself, so no token sits in the jobs table. A failed send is retried with backoff, and a job past 60 seconds is killed, so a hung mail relay costs a queue slot rather than the database. Because the response no longer waits, the `503 email_delivery_failed` answer is gone: the account is kept and the email retried. A `before_user_register` plugin that refuses the sign-up now answers `403 registration_refused`, with its message when it returns a string; before, it was reported as that 503. The password is also hashed once per sign-up instead of twice: the tentative user handed to `before_user_register` plugins was hashed as well, and no longer is (nor carries the plaintext). + +- [fixed] **An IPv6 client was rate-limited per address.** One IPv6 subscriber is routinely handed a /64, 2^64 addresses, so the per-IP limits (10 sign-ups or logins a minute, 240 requests) did not hold for anyone on IPv6. HTTP and LiveView limits now key IPv6 by /64 (`GamendWeb.RateLimit.ip_key/1`). An IPv4-mapped address (`::ffff:1.2.3.4`) is keyed as its IPv4, so IPv4 clients behind a dual-stack listener keep their own buckets. The captcha still sees the exact address. + +- [added] **Personal API tokens.** A script or CI job had only `POST /api/v1/login`: a password (which a social-login account does not have) for a fifteen-minute token. Settings → **API tokens** now makes a named `gamend_pat_…` token that lasts 30, 90 or 365 days, or never, accepted as a Bearer token on every route an access token reaches (`GamendWeb.Auth.ApiTokenAuth`, first in both API pipelines, handing Guardian the claims an access token carries so the deactivation check still applies). Only a SHA-256 is stored and the token is shown once. A password or email change, or signing out everywhere, retires every token made before it — each remembers the `token_version` it was made under — so a stolen session cannot leave a token behind that outlives the reset. Tokens are made only on the settings page, never through the API. `Gamend.Accounts.ApiTokens`; limit `GAMEND_LIMITS_MAX_API_TOKENS_PER_USER` (default 10); retention class `dead_api_tokens`; guide under Authentication. +- [fixed] **Relative links in a folder's `index.md` pointed one level up.** A link resolves against the document's folder, which for a page is its slug's parent — but an `index.md`'s slug already names its folder, so taking the parent there sent `[Builds](builds.md)` in `forge/index.md` to `/docs/builds`, and every `./assets/mesh.md` on a generated reference landing page to a 404. `Gamend.Content.Markdown.render_file/2` now marks an index file (`:index`) and its links resolve against its own slug. - [fixed] **A plugin never loaded in an OTP release.** A release runs the code server in embedded mode, where nothing loads on first call and `Code.ensure_loaded/1` answers `{:error, :embedded}` for any module the boot script did not load — and a plugin is never in the boot script. So every plugin failed at boot with `plugin=… failed to load module=… {:error, :embedded}` in the `release` image while working under `mix phx.server`. `Gamend.Hooks.PluginManager` now loads each beam on a plugin's own paths explicitly when the code server is embedded, before the app is loaded and started; interactive mode is unchanged. - [added] **Accounts that never confirmed their email are deleted after 30 days idle.** `GAMEND_RETENTION_UNCONFIRMED_USERS_DAYS` (default 30, `0` keeps forever) sweeps accounts whose only identity is an email never confirmed, idle for that long (`coalesce(last_seen_at, inserted_at)`), with the exemptions every user sweep has: admins, and anyone holding a purchase or entitlement. A sign-up costs one request, and nothing pruned these, so they were the tier a bot could fill for good. An account that also holds a provider login is kept. Activity decides, not age, because `POST /api/v1/register` signs in unconfirmed and a player still playing keeps the account. Expired `confirm` tokens are now pruned with the other expired tokens. The admin Users page adds an "Unverified email" filter, also reachable as `/admin/users?filter=unverified`. - [added] **A captcha option for `POST /api/v1/register`.** With `GAMEND_CAPTCHA_ENABLED` on, `GAMEND_CAPTCHA_API_REGISTER=true` (default off) requires a Turnstile token in the new `captcha_token` field: `403 captcha_required` or `captcha_invalid`, `503 captcha_unavailable` when Cloudflare cannot be reached. Off by default, because a game client with no browser cannot render the widget. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21c9101aa..2c614f5e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,10 +39,10 @@ Adding one callback touches six places — miss one and plugins break in confusi 2. Add the name to `internal_hooks()` — otherwise clients can invoke it over RPC. 3. `before_*` hooks: add to `lifecycle_pipeline_hook?/2`, plus a `normalize_pipeline_args/3` clause if the hook only vetoes (returns the value unchanged). 4. No-op implementation in `Gamend.Hooks.Default`. -5. Mirror in the SDK (`sdk/lib/gamend/hooks.ex`): `@callback`, `@optional_callbacks`, a default in `__using__`, **and the `defoverridable` list** — a default that isn't listed there cannot be overridden by plugins. +5. Mirror in the SDK (`sdk/lib/gamend/hooks.ex`): `@callback`, `@optional_callbacks`, a default in `default_callbacks` or `more_default_callbacks` (the quoted code `__using__` injects), **and the `overridable_callbacks` list** — a default that isn't listed there cannot be overridden by plugins. 6. Document the hook in `priv/docs/40-gameplay/90-server-scripting.md`. -**Never dispatch a hook or broadcast inside a transaction or lock.** The hook runs in another process, so anything it writes contends with the transaction that spawned it. Queue the effect and flush it after commit (see `defer/1` in `Gamend.Tournaments`). This also keeps subscribers from seeing uncommitted state. +**Hold the database only for database work.** On SQLite the repo has a single connection and every transaction takes the write lock, so anything slow inside a transaction or `Gamend.Lock.serialize/3` stalls every other request. Open transactions with `Gamend.AfterCommit.transaction/2` (or `serialize/3`) and broadcast with `Gamend.Broadcast.publish/2`: broadcasts, `Gamend.Async.run/1` tasks and anything passed to `Gamend.AfterCommit.defer/1` then wait for the commit, and a rollback drops them. A test in `after_commit_test.exs` fails on a bare `Repo.transaction` or `Phoenix.PubSub.broadcast` in core. Slow gates run *before* the lock: a plugin's `before_*` hook, a password check, an HTTP call. Inside it, re-check only what a concurrent writer could change (see `Lobbies.join_lobby/3`). When the hook needs the value the lock protects, go optimistic: read and ask the hook unlocked, then write under the lock only if the value is unchanged, and retry otherwise (`Lobbies.merge_metadata/2`). ## SDK (plugin-facing) diff --git a/apps/gamend_core/config/test.exs b/apps/gamend_core/config/test.exs index 3b39df41d..3daf5accd 100644 --- a/apps/gamend_core/config/test.exs +++ b/apps/gamend_core/config/test.exs @@ -82,6 +82,10 @@ config :gamend_core, Gamend.Accounts.PresenceWriter, flush_ms: 0 # connection, and on SQLite they collide with the test's open write transaction # ("database is locked"). Tests drive tick/0 and sweep/0 directly. config :gamend_core, Gamend.Tournaments.Ticker, enabled: false + +# The live retention cycle would sweep outside any sandbox every minute; the +# full sweep's first run is five minutes out, past any test. +config :gamend_core, Gamend.Retention, live_interval_seconds: 0 config :gamend_core, Gamend.Matchmaking.Worker, enabled: false # NOTE: deliberately NOT setting `async_inline: true` here, unlike the root diff --git a/apps/gamend_core/lib/gamend/accounts.ex b/apps/gamend_core/lib/gamend/accounts.ex index 729eb067f..1037e6adf 100644 --- a/apps/gamend_core/lib/gamend/accounts.ex +++ b/apps/gamend_core/lib/gamend/accounts.ex @@ -25,6 +25,7 @@ defmodule Gamend.Accounts do alias Gamend.Accounts.{ Broadcasts, Identities, + LoginLockouts, PasswordHash, Presence, Profile, @@ -39,11 +40,9 @@ defmodule Gamend.Accounts do # Upper bound on cross-node staleness for cached user structs: explicit # invalidations propagate immediately via `Gamend.Cache.invalidate/1`, - # and this TTL caps staleness if an invalidation broadcast is ever missed. - @user_cache_ttl_ms 60_000 - + # and the cache TTL caps staleness if an invalidation broadcast is ever missed. @doc false - def user_cache_ttl_ms, do: @user_cache_ttl_ms + def user_cache_ttl_ms, do: Gamend.Cache.ttl() @doc false def users_stats_cache_version do @@ -316,7 +315,8 @@ defmodule Gamend.Accounts do to: Registration @doc """ - Gets a user by email and password. + Gets a user by email and password. `nil` for a wrong password, and for an + address locked by too many failures (`authenticate_by_password/2` says which). ## Examples @@ -330,11 +330,41 @@ defmodule Gamend.Accounts do @spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do + case authenticate_by_password(email, password) do + {:ok, user} -> user + {:error, _reason} -> nil + end + end + + @doc """ + Checks an email and password, counting failures per address + (`Gamend.Accounts.LoginLockouts`). + + `{:error, {:locked, seconds}}` when the address is locked, before the + password is looked at, and for the failure that locks it. + """ + @spec authenticate_by_password(String.t(), String.t()) :: + {:ok, User.t()} | {:error, :invalid_credentials | {:locked, pos_integer()}} + def authenticate_by_password(email, password) + when is_binary(email) and is_binary(password) do + case LoginLockouts.check(email) do + :ok -> check_password(email, password) + {:locked, seconds} -> {:error, {:locked, seconds}} + end + end + + defp check_password(email, password) do user = get_user_by_email(email) if User.valid_password?(user, password) do maybe_upgrade_password_hash(user, password) - user + LoginLockouts.clear(email) + {:ok, user} + else + case LoginLockouts.record_failure(email) do + :ok -> {:error, :invalid_credentials} + {:locked, seconds} -> {:error, {:locked, seconds}} + end end end @@ -414,7 +444,7 @@ defmodule Gamend.Accounts do @decorate cacheable( key: {:accounts, :user, id}, match: &cache_match/1, - opts: [ttl: @user_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_user(id), do: Repo.get_uuid(User, id) @@ -528,7 +558,7 @@ defmodule Gamend.Accounts do key: {:accounts, :user_by, field, value}, references: &(&1 && keyref({:accounts, :user, &1.id})), match: &cache_match/1, - opts: [ttl: @user_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_user_by_field(field, value) when is_atom(field) do Repo.get_by(User, [{field, value}]) @@ -546,7 +576,7 @@ defmodule Gamend.Accounts do # Evict on all other instances first so their L1 refetches the fresh # struct; the put re-warms this node and the shared L2. _ = Gamend.Cache.invalidate({:accounts, :user, user.id}) - _ = Gamend.Cache.put({:accounts, :user, user.id}, user, ttl: @user_cache_ttl_ms) + _ = Gamend.Cache.put({:accounts, :user, user.id}, user, ttl: Gamend.Cache.ttl()) user end @@ -726,6 +756,84 @@ defmodule Gamend.Accounts do doc: "JWT signing key. Defaults to secret_key_base when unset." ) + setting(:access_token_ttl_minutes, :integer, + default: 15, + doc: + "Lifetime of API access tokens, in minutes. Login and refresh answer it as expires_in. " <> + "Applies to tokens issued after the change." + ) + + setting(:refresh_token_ttl_days, :integer, + default: 30, + doc: + "Lifetime of API refresh tokens, in days. A refresh keeps its token, so this is how " <> + "long a client stays signed in without logging in again." + ) + + setting(:session_days, :integer, + default: 14, + doc: + "Lifetime of a browser session and its remember-me cookie, in days. An active " <> + "session is renewed once it is half this old." + ) + + setting(:magic_link_minutes, :integer, + default: 15, + doc: + "How long an emailed login link stays valid, in minutes. Capped at 60: anyone who " <> + "can read the email can sign in while the link lives." + ) + + setting(:confirm_email_days, :integer, + default: 7, + doc: "How long an email confirmation link stays valid, in days." + ) + + setting(:change_email_days, :integer, + default: 7, + doc: "How long the link confirming a new email address stays valid, in days." + ) + + setting(:sudo_mode_minutes, :integer, + default: 10, + doc: + "How recently a user must have signed in to open the settings that change their " <> + "password or email. Submitting the form is allowed 10 minutes more." + ) + + setting(:api_token_max_days, :integer, + default: 0, + doc: + "Longest lifetime a personal API token may have, in days. Applies to existing tokens " <> + "too, counted from creation. 0 allows tokens that never expire." + ) + + setting(:lockout_attempts, :integer, + default: 10, + doc: + "Failed passwords for one email address that lock its password sign-in. Counted per " <> + "address across every IP. 0 disables the lockout." + ) + + setting(:lockout_window_minutes, :integer, + default: 15, + doc: "The failures must fall within this many minutes to lock." + ) + + setting(:lockout_minutes, :integer, + default: 15, + doc: + "How long a lock lasts. Emailed login links and provider sign-in still work " <> + "meanwhile, so the owner is never shut out." + ) + + setting(:deletion_grace_days, :integer, + default: 0, + doc: + "Days between a player deleting their own account and it being deleted. Signing in " <> + "on the website within that time keeps the account. 0 deletes at once." + ) + @doc "Whether device-based auth is enabled. Defaults to on." @spec device_auth_enabled?() :: boolean() def device_auth_enabled?, do: Gamend.Settings.get(__MODULE__, :device_auth_enabled) == true @@ -754,15 +862,25 @@ defmodule Gamend.Accounts do ## Settings + # Opening a sudo page needs a sign-in within `sudo_mode_minutes`; submitting + # its form gets this much longer, so a user who opened the page just inside + # the window can still finish typing. + @sudo_form_grace_minutes 10 + + @doc "How recently a user must have signed in to open a sudo page (`auth.sudo_mode_minutes`)." + @spec sudo_mode_minutes() :: pos_integer() + def sudo_mode_minutes, do: max(Gamend.Settings.get(__MODULE__, :sudo_mode_minutes), 1) + @doc """ Checks whether the user is in sudo mode. - The user is in sudo mode when the last authentication was done no further - than 20 minutes ago. The limit can be given as second argument in minutes. + With one argument, the window is the one a sudo form is submitted in: + `sudo_mode_minutes/0` plus ten minutes to fill the form in. The limit can be + given as second argument in minutes (negative, as an offset from now). """ @spec sudo_mode?(User.t()) :: boolean() @spec sudo_mode?(User.t(), integer()) :: boolean() - def sudo_mode?(user, minutes \\ -20) + def sudo_mode?(user), do: sudo_mode?(user, -(sudo_mode_minutes() + @sudo_form_grace_minutes)) def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) @@ -798,7 +916,7 @@ defmodule Gamend.Accounts do def update_user_email(user, token) do context = "change:#{user.email}" - Repo.transact(fn -> + Gamend.AfterCommit.transact(fn -> with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), %UserToken{sent_to: email} <- Repo.one(query), # Bump `token_version` with the address change, so JWTs issued to the @@ -862,6 +980,74 @@ defmodule Gamend.Accounts do |> update_user_and_delete_all_tokens() end + @doc "Days a player's own deletion waits (`auth.deletion_grace_days`); 0 deletes at once." + @spec deletion_grace_days() :: non_neg_integer() + def deletion_grace_days, do: max(Gamend.Settings.get(__MODULE__, :deletion_grace_days), 0) + + @doc """ + A player deleting their own account. + + With `auth.deletion_grace_days` at 0 the account is deleted now, by + `delete_user/1`. Otherwise it is scheduled that many days out and signed out + everywhere (every session, access, refresh and personal API token), and + `Gamend.Retention` deletes it on the day unless its owner signs in on the + website first (`cancel_deletion/1`). An account already scheduled keeps its + date. The expired session tokens come back so the caller can disconnect + their LiveViews. + + Admin deletions and the retention sweeps call `delete_user/1` and never wait. + """ + @spec request_deletion(User.t()) :: + {:ok, :deleted} + | {:ok, {:scheduled, User.t(), [UserToken.t()]}} + | {:error, Ecto.Changeset.t()} + def request_deletion(%User{} = user) do + case deletion_grace_days() do + 0 -> + with {:ok, _user} <- delete_user(user), do: {:ok, :deleted} + + days -> + at = user.deletion_scheduled_at || DateTime.add(DateTime.utc_now(:second), days, :day) + + with {:ok, {user, tokens}} <- + user + |> Ecto.Changeset.change(deletion_scheduled_at: at) + |> update_user_and_delete_all_tokens() do + {:ok, {:scheduled, user, tokens}} + end + end + end + + @doc "Whether `user` is waiting out a deletion grace period." + @spec deletion_scheduled?(User.t() | nil) :: boolean() + def deletion_scheduled?(%User{deletion_scheduled_at: %DateTime{}}), do: true + def deletion_scheduled?(_user), do: false + + @doc """ + Keep an account that was scheduled for deletion. A no-op for one that was not. + """ + @spec cancel_deletion(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def cancel_deletion(%User{deletion_scheduled_at: nil} = user), do: {:ok, user} + + def cancel_deletion(%User{} = user) do + with {:ok, user} <- + user |> Ecto.Changeset.change(deletion_scheduled_at: nil) |> Repo.update() do + invalidate_user_cache(user) + cache_user(user) + {:ok, user} + end + end + + @doc "Accounts whose deletion date has passed. For `Gamend.Retention`." + @spec due_deletions_query() :: Ecto.Query.t() + def due_deletions_query do + now = DateTime.utc_now(:second) + + from(u in User, + where: not is_nil(u.deletion_scheduled_at) and u.deletion_scheduled_at <= ^now + ) + end + @doc """ Deletes a user and associated resources. @@ -962,7 +1148,7 @@ defmodule Gamend.Accounts do @doc false def update_user_and_delete_all_tokens(changeset) do - Repo.transact(fn -> + Gamend.AfterCommit.transact(fn -> changeset = bump_token_version(changeset) with {:ok, user} <- Repo.update(changeset) do @@ -1057,23 +1243,27 @@ defmodule Gamend.Accounts do @spec update_user(User.t(), Types.user_update_attrs()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def update_user(%User{} = user, attrs) when is_map(attrs) do + with {:ok, attrs_to_use} <- run_before_user_update(user, attrs) do + apply_user_update(user, attrs_to_use) + end + end + + # `update_user/2` in two halves, for a read-modify-write that must not hold + # its lock across the plugin's hook: ask the hook first, then write under the + # lock (`Gamend.Hooks.Default`'s payment metadata). + @doc false + @spec run_before_user_update(User.t(), map()) :: {:ok, map()} | {:error, term()} + def run_before_user_update(%User{} = user, attrs) when is_map(attrs) do case Gamend.Hooks.internal_call(:before_user_update, [user, attrs]) do - {:ok, returned} -> - attrs_to_use = - if is_map(returned) and not is_struct(returned) do - returned - else - attrs - end - - do_update_user(user, attrs_to_use) - - {:error, reason} -> - {:error, reason} + {:ok, returned} when is_map(returned) and not is_struct(returned) -> {:ok, returned} + {:ok, _other} -> {:ok, attrs} + {:error, reason} -> {:error, reason} end end - defp do_update_user(%User{} = user, attrs) do + @doc false + @spec apply_user_update(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def apply_user_update(%User{} = user, attrs) do case user |> User.admin_changeset(attrs) |> revoke_on_deactivation() |> Repo.update() do {:ok, updated} = ok -> invalidate_user_cache(user) diff --git a/apps/gamend_core/lib/gamend/accounts/api_token.ex b/apps/gamend_core/lib/gamend/accounts/api_token.ex new file mode 100644 index 000000000..357c86cd2 --- /dev/null +++ b/apps/gamend_core/lib/gamend/accounts/api_token.ex @@ -0,0 +1,116 @@ +defmodule Gamend.Accounts.ApiToken do + @moduledoc """ + A personal API token's row. The token itself is never stored. + + Fields: + + - `name` – what the owner called it, so they can tell two apart + - `token_hash` – SHA-256 of the full token; the lookup key + - `hint` – the first characters after the prefix, shown in lists so a token + in a CI secret can be matched to its row without revealing it + - `token_version` – the owner's `users.token_version` at creation; a later + password or email change leaves this behind and the token stops working + - `expires_at` – nil for a token that does not expire. `auth.api_token_max_days` + can end a token sooner; `Gamend.Accounts.ApiTokens.expires_at/1` has the + effective date + - `last_used_at` – bumped at most once a minute + """ + use Gamend.Schema + import Ecto.Changeset + + alias Gamend.Accounts.User + + # Days a new token may be given when no cap is set, nil being "does not + # expire". A cap keeps the ones below it and adds itself. + @standard_choices [30, 90, 365, nil] + + schema "api_tokens" do + belongs_to :user, User + + field :name, :string + field :token_hash, :binary, redact: true + field :hint, :string + field :token_version, :integer, default: 0 + field :expires_at, :utc_datetime + field :last_used_at, :utc_datetime + + field :expires_in_days, :integer, virtual: true + + timestamps(type: :utc_datetime) + end + + @typedoc "A personal API token's row." + @type t :: %__MODULE__{ + id: String.t() | nil, + user_id: String.t() | nil, + name: String.t() | nil, + token_hash: binary() | nil, + hint: String.t() | nil, + token_version: integer(), + expires_at: DateTime.t() | nil, + last_used_at: DateTime.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + The lifetimes a token may be created with, in days; nil never expires. With + `auth.api_token_max_days` set, the choices stop at the cap (which is itself + one) and nil is gone. + """ + @spec expiry_choices() :: [pos_integer() | nil] + def expiry_choices do + case max_days() do + nil -> @standard_choices + max -> Enum.sort([max | Enum.filter(@standard_choices, &(is_integer(&1) and &1 < max))]) + end + end + + @doc "`auth.api_token_max_days`, or nil when a token may live forever." + @spec max_days() :: pos_integer() | nil + def max_days do + case Gamend.Settings.get(Gamend.Accounts, :api_token_max_days) do + days when is_integer(days) and days > 0 -> days + _uncapped -> nil + end + end + + @doc """ + A new token's name and lifetime. `user_id`, `token_hash`, `hint` and + `token_version` are set by `Gamend.Accounts.ApiTokens.create/2`, never cast. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(token, attrs) do + token + |> cast(attrs, [:name, :expires_in_days]) + |> update_change(:name, &String.trim/1) + |> validate_required([:name]) + |> validate_length(:name, max: 100) + |> validate_expiry() + |> put_expiry() + end + + # `validate_inclusion/3` skips a nil value, and nil ("never") is exactly what a + # cap forbids, so that case is checked by hand. + defp validate_expiry(changeset) do + choices = expiry_choices() + changeset = validate_inclusion(changeset, :expires_in_days, choices) + + if is_nil(get_field(changeset, :expires_in_days)) and nil not in choices do + add_error(changeset, :expires_in_days, "can't be blank", validation: :required) + else + changeset + end + end + + defp put_expiry(changeset) do + case get_field(changeset, :expires_in_days) do + days when is_integer(days) -> + at = DateTime.utc_now() |> DateTime.add(days, :day) |> DateTime.truncate(:second) + put_change(changeset, :expires_at, at) + + _never -> + changeset + end + end +end diff --git a/apps/gamend_core/lib/gamend/accounts/api_tokens.ex b/apps/gamend_core/lib/gamend/accounts/api_tokens.ex new file mode 100644 index 000000000..d71b68bd1 --- /dev/null +++ b/apps/gamend_core/lib/gamend/accounts/api_tokens.ex @@ -0,0 +1,238 @@ +defmodule Gamend.Accounts.ApiTokens do + @moduledoc """ + Personal API tokens: long-lived bearer tokens for scripts and CI. + + An access token from `POST /api/v1/login` lasts fifteen minutes and needs a + password, which suits a game client and nothing that runs unattended — and an + account made with a social login has no password at all. A personal token is + created once on the settings page, sent as `Authorization: Bearer + gamend_pat_…` to any API route that takes an access token, and lasts until it + expires or is revoked. + + ## What is kept + + Only a SHA-256 of the token, under a unique index: a request is one indexed + lookup, and the table holds nothing a client could send. The token is shown + once, at creation. `hint` is its first few characters, so a list can say + which token a CI secret holds without holding it. + + ## When a token stops working + + - It expires (`expires_at/1`: its own `expires_at`, or `auth.api_token_max_days` + after creation when that is sooner), or its owner revokes it. + - Its owner changes their password or email, or signs out everywhere: each + bumps `users.token_version`, and a token remembers the version it was made + under. A stolen session therefore cannot leave a token behind that + survives the password reset that ends it. + - The account is deactivated — the same check an access token gets, in + `GamendWeb.Auth.Guardian.resource_from_claims/1`. + + Tokens are made only through the settings page, never through the API, so a + leaked token cannot mint another. `Gamend.Retention` prunes expired and + superseded rows. + """ + + import Ecto.Query + + alias Gamend.Accounts.ApiToken + alias Gamend.Accounts.User + alias Gamend.Repo + + @prefix "gamend_pat_" + @hint_length 6 + # A token used in a tight loop would otherwise write its row on every call. + @touch_every_seconds 60 + + @doc "What every personal token starts with; how the auth plug tells one from a JWT." + @spec prefix() :: String.t() + def prefix, do: @prefix + + @doc "Whether `value` is shaped like a personal token." + @spec token?(term()) :: boolean() + def token?(value) when is_binary(value), do: String.starts_with?(value, @prefix) + def token?(_value), do: false + + @doc """ + Create a token for `user`. Answers the token itself — the only time it + exists outside the caller — and the stored row. + + `attrs` takes `name` and `expires_in_days`, one of `ApiToken.expiry_choices/0` + (30, 90, 365, or nil for none, unless `auth.api_token_max_days` caps them). + Refused with `:limit_reached` past `Gamend.Limits` `max_api_tokens_per_user`. + """ + @spec create(User.t(), map()) :: + {:ok, String.t(), ApiToken.t()} | {:error, Ecto.Changeset.t() | :limit_reached} + def create(%User{} = user, attrs) do + if count(user.id) >= Gamend.Limits.get(:max_api_tokens_per_user) do + {:error, :limit_reached} + else + secret = 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + token = @prefix <> secret + + %ApiToken{ + user_id: user.id, + token_hash: hash(token), + hint: String.slice(secret, 0, @hint_length), + token_version: user.token_version || 0 + } + |> ApiToken.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, row} -> {:ok, token, row} + {:error, changeset} -> {:error, changeset} + end + end + end + + @doc """ + The owner and row of a presented token, or `:error` when it is unknown, + expired, or older than its owner's last credential change. + """ + @spec verify(String.t()) :: {:ok, User.t(), ApiToken.t()} | :error + def verify(token) when is_binary(token) do + with true <- token?(token), + %ApiToken{user: %User{} = user} = row <- get_by_hash(hash(token)), + true <- live?(row, user) do + {:ok, user, row} + else + _ -> :error + end + end + + def verify(_token), do: :error + + defp get_by_hash(digest) do + Repo.one(from(t in ApiToken, where: t.token_hash == ^digest, preload: :user)) + end + + defp live?(%ApiToken{} = row, %User{} = user) do + row.token_version == (user.token_version || 0) and not expired?(row) + end + + @doc """ + When the token stops working on its own: its `expires_at`, or + `auth.api_token_max_days` after it was made when that comes first. nil when + it never expires. The cap reaches tokens made before it was set. + """ + @spec expires_at(ApiToken.t()) :: DateTime.t() | nil + def expires_at(%ApiToken{expires_at: own, inserted_at: made}) do + case ApiToken.max_days() do + nil -> + own + + days -> + capped = DateTime.add(made, days, :day) + if own && DateTime.before?(own, capped), do: own, else: capped + end + end + + @doc "Whether the token's own lifetime has run out." + @spec expired?(ApiToken.t()) :: boolean() + def expired?(%ApiToken{} = row) do + case expires_at(row) do + nil -> false + at -> DateTime.compare(at, DateTime.utc_now()) != :gt + end + end + + @doc """ + Whether the owner's password or email changed since the token was made. + Such a token is dead; the settings page says so rather than listing it as + working. + """ + @spec superseded?(ApiToken.t(), User.t()) :: boolean() + def superseded?(%ApiToken{token_version: version}, %User{token_version: current}), + do: version != (current || 0) + + @doc """ + Record a use, at most once a minute per token, off the request path: the + caller is an authenticated request and should not wait on a write. + """ + @spec touch(ApiToken.t()) :: :ok + def touch(%ApiToken{id: id, last_used_at: last}) do + now = DateTime.utc_now(:second) + + if is_nil(last) or DateTime.diff(now, last) >= @touch_every_seconds do + Gamend.Async.run(fn -> + cutoff = DateTime.add(now, -@touch_every_seconds, :second) + + Repo.update_all( + from(t in ApiToken, + where: t.id == ^id and (is_nil(t.last_used_at) or t.last_used_at <= ^cutoff) + ), + set: [last_used_at: now] + ) + end) + end + + :ok + end + + @doc "A user's tokens, newest first." + @spec list(String.t(), keyword()) :: [ApiToken.t()] + def list(user_id, opts \\ []) when is_binary(user_id) do + page = Keyword.get(opts, :page, 1) + page_size = Keyword.get(opts, :page_size, 25) + + from(t in ApiToken, + where: t.user_id == ^user_id, + order_by: [desc: t.inserted_at, desc: t.id], + limit: ^page_size, + offset: ^((page - 1) * page_size) + ) + |> Repo.all() + end + + @doc "Count for `list/2`'s pagination." + @spec count(String.t()) :: non_neg_integer() + def count(user_id) when is_binary(user_id) do + Repo.one(from(t in ApiToken, where: t.user_id == ^user_id, select: count(t.id))) || 0 + end + + @doc "Revoke one of the user's tokens. It stops working on the next request." + @spec revoke(String.t(), String.t()) :: {:ok, ApiToken.t()} | {:error, :not_found} + def revoke(user_id, id) when is_binary(user_id) and is_binary(id) do + case Repo.one(from(t in ApiToken, where: t.user_id == ^user_id and t.id == ^id)) do + nil -> {:error, :not_found} + row -> Repo.delete(row) + end + end + + @doc """ + Rows no request can use any more: past `expires_at`, or made under an older + `token_version` than their owner's. For `Gamend.Retention`. + """ + # + # The join sits in a subquery so the outer query is a plain one on + # `api_tokens`: retention deletes through it, and SQLite has no + # `DELETE … JOIN`. + @spec dead_query() :: Ecto.Query.t() + def dead_query do + now = DateTime.utc_now() + + dead = + dynamic( + [t, u], + (not is_nil(t.expires_at) and t.expires_at <= ^now) or + t.token_version != coalesce(u.token_version, 0) + ) + + dead = + case ApiToken.max_days() do + nil -> dead + days -> dynamic([t], ^dead or t.inserted_at <= ^DateTime.add(now, -days, :day)) + end + + dead_ids = + from(t in ApiToken, + join: u in User, + on: u.id == t.user_id, + where: ^dead, + select: t.id + ) + + from(t in ApiToken, where: t.id in subquery(dead_ids)) + end + + defp hash(token), do: :crypto.hash(:sha256, token) +end diff --git a/apps/gamend_core/lib/gamend/accounts/avatar_mirror.ex b/apps/gamend_core/lib/gamend/accounts/avatar_mirror.ex index 3ab264253..59fe8c813 100644 --- a/apps/gamend_core/lib/gamend/accounts/avatar_mirror.ex +++ b/apps/gamend_core/lib/gamend/accounts/avatar_mirror.ex @@ -94,7 +94,7 @@ defmodule Gamend.Accounts.AvatarMirror do end defp fetch(source_url, req_opts) do - Req.get(source_url, [decode_body: false] ++ req_opts) + Gamend.HTTP.get(source_url, [decode_body: false] ++ req_opts) rescue e -> {:error, e} end diff --git a/apps/gamend_core/lib/gamend/accounts/broadcasts.ex b/apps/gamend_core/lib/gamend/accounts/broadcasts.ex index ac9db31a0..8e9e6e6f6 100644 --- a/apps/gamend_core/lib/gamend/accounts/broadcasts.ex +++ b/apps/gamend_core/lib/gamend/accounts/broadcasts.ex @@ -5,6 +5,9 @@ defmodule Gamend.Accounts.Broadcasts do Split out of `Gamend.Accounts`, which still exposes every function here under the same name. + + Each runs after commit when called inside a transaction (`Gamend.AfterCommit`), + lookups included, so the fan-out sees and announces only committed state. """ import Ecto.Query, warn: false @@ -19,16 +22,17 @@ defmodule Gamend.Accounts.Broadcasts do """ @spec broadcast_user_update(User.t()) :: :ok def broadcast_user_update(%User{} = user) do - payload = serialize_user_payload(user) - topic = "user:#{user.id}" + Gamend.AfterCommit.defer(fn -> + payload = serialize_user_payload(user) + topic = "user:#{user.id}" - Phoenix.PubSub.broadcast( - Gamend.PubSub, - topic, - %Phoenix.Socket.Broadcast{topic: topic, event: "updated", payload: payload} - ) + Gamend.Broadcast.publish( + topic, + %Phoenix.Socket.Broadcast{topic: topic, event: "updated", payload: payload} + ) - :ok + :ok + end) end @doc """ @@ -41,30 +45,32 @@ defmodule Gamend.Accounts.Broadcasts do """ @spec broadcast_member_update(User.t()) :: :ok def broadcast_member_update(%User{} = user) do - if user.lobby_id do - Gamend.Lobbies.broadcast_member_presence( - user.lobby_id, - {:member_updated, user.id} - ) - end - - if user.party_id do - Gamend.Parties.broadcast_member_presence( - user.party_id, - {:member_updated, user.id} - ) - end - - # Broadcast to all groups the user belongs to - for group_id <- Gamend.Groups.user_group_ids(user.id) do - Gamend.Groups.broadcast_member_presence( - group_id, - {:member_updated, user.id} - ) - end - - broadcast_friend_update(user) - :ok + Gamend.AfterCommit.defer(fn -> + if user.lobby_id do + Gamend.Lobbies.broadcast_member_presence( + user.lobby_id, + {:member_updated, user.id} + ) + end + + if user.party_id do + Gamend.Parties.broadcast_member_presence( + user.party_id, + {:member_updated, user.id} + ) + end + + # Broadcast to all groups the user belongs to + for group_id <- Gamend.Groups.user_group_ids(user.id) do + Gamend.Groups.broadcast_member_presence( + group_id, + {:member_updated, user.id} + ) + end + + broadcast_friend_update(user) + :ok + end) end @doc """ @@ -75,19 +81,20 @@ defmodule Gamend.Accounts.Broadcasts do """ @spec broadcast_friend_update(User.t()) :: :ok def broadcast_friend_update(%User{} = user) do - payload = User.serialize_brief(user) |> Map.put(:user_id, user.id) + Gamend.AfterCommit.defer(fn -> + payload = User.serialize_brief(user) |> Map.put(:user_id, user.id) - for friend_id <- Gamend.Friends.friend_ids(user.id) do - topic = "user:#{friend_id}" + for friend_id <- Gamend.Friends.friend_ids(user.id) do + topic = "user:#{friend_id}" - Phoenix.PubSub.broadcast( - Gamend.PubSub, - topic, - %Phoenix.Socket.Broadcast{topic: topic, event: "friend_updated", payload: payload} - ) - end + Gamend.Broadcast.publish( + topic, + %Phoenix.Socket.Broadcast{topic: topic, event: "friend_updated", payload: payload} + ) + end - :ok + :ok + end) end @doc """ diff --git a/apps/gamend_core/lib/gamend/accounts/confirmation_mailer.ex b/apps/gamend_core/lib/gamend/accounts/confirmation_mailer.ex new file mode 100644 index 000000000..86661e15a --- /dev/null +++ b/apps/gamend_core/lib/gamend/accounts/confirmation_mailer.ex @@ -0,0 +1,68 @@ +defmodule Gamend.Accounts.ConfirmationMailer do + @moduledoc """ + Sends a new account's confirmation email, off the request. + + Registration enqueues this in the transaction that inserts the user, so a + committed account always has its email queued and the transaction holds the + database for two inserts rather than an SMTP conversation. On SQLite the + repo has a single connection, and a registration that sent its mail inline + stalled every other query in the server for the length of the SMTP session. + + The job mints the token itself: a token in the job's args would sit in the + jobs table in the clear. A failed send is retried with backoff, minting a new + token each attempt (the unused ones expire). A job that outlives `timeout/1` + is killed, so a hung relay costs one `mailers` slot, never the database. + """ + + use Oban.Worker, queue: :mailers, max_attempts: 5 + + alias Gamend.Accounts.{User, UserNotifier, UserToken} + alias Gamend.Repo + + # Stands in for the token when the URL is built at enqueue time, where the + # router is at hand; the job swaps in the real one. URL-safe, so a verified + # route passes it through unencoded. + @token "__gamend_confirm_token__" + + @doc """ + The job for `user`. `confirmation_url_fun` maps an encoded token to its URL + and runs now, with a placeholder; `notifier` delivers. + """ + @spec new_for(User.t(), (String.t() -> String.t()), module()) :: Ecto.Changeset.t() + def new_for(%User{id: id}, confirmation_url_fun, notifier \\ UserNotifier) + when is_function(confirmation_url_fun, 1) and is_atom(notifier) do + url = confirmation_url_fun.(@token) + + unless String.contains?(url, @token) do + raise ArgumentError, "confirmation URL #{inspect(url)} dropped its token" + end + + new(%{"user_id" => id, "url" => url, "notifier" => Atom.to_string(notifier)}) + end + + @impl Oban.Worker + def timeout(_job), do: :timer.seconds(60) + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"user_id" => user_id, "url" => url, "notifier" => notifier}}) do + # Straight from the table, not the user cache: a stale cached copy could + # still read unconfirmed. + case Repo.get(User, user_id) do + %User{confirmed_at: nil, email: email} = user when is_binary(email) -> + deliver(user, url, String.to_existing_atom(notifier)) + + _gone_or_confirmed -> + :ok + end + end + + defp deliver(user, url, notifier) do + {encoded, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + + case notifier.deliver_confirmation_instructions(user, String.replace(url, @token, encoded)) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end +end diff --git a/apps/gamend_core/lib/gamend/accounts/login_lockout.ex b/apps/gamend_core/lib/gamend/accounts/login_lockout.ex new file mode 100644 index 000000000..fa914cb75 --- /dev/null +++ b/apps/gamend_core/lib/gamend/accounts/login_lockout.ex @@ -0,0 +1,36 @@ +defmodule Gamend.Accounts.LoginLockout do + @moduledoc """ + Failed password sign-ins for one email address, and the lock they set. + + Fields: + + - `key_hash` – SHA-256 of the normalized address; the lookup key. The address + itself is never stored + - `failures` – failed attempts in the current window + - `window_started_at` – when the current window's first failure happened + - `locked_until` – nil, or when the lock the failures set runs out + + See `Gamend.Accounts.LoginLockouts`. + """ + use Gamend.Schema + + schema "login_lockouts" do + field :key_hash, :binary, redact: true + field :failures, :integer, default: 0 + field :window_started_at, :utc_datetime + field :locked_until, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @typedoc "A row of failed sign-ins for one address." + @type t :: %__MODULE__{ + id: String.t() | nil, + key_hash: binary() | nil, + failures: non_neg_integer(), + window_started_at: DateTime.t() | nil, + locked_until: DateTime.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } +end diff --git a/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex b/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex new file mode 100644 index 000000000..cbbd06cfa --- /dev/null +++ b/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex @@ -0,0 +1,134 @@ +defmodule Gamend.Accounts.LoginLockouts do + @moduledoc """ + Per-account lockout after repeated failed password sign-ins. + + The per-IP `auth` rate limit caps how fast one address can guess; it does + nothing against guesses spread over many addresses, which is what a botnet + aimed at one account does. So failures are also counted per email address: + `auth.lockout_attempts` of them within `auth.lockout_window_minutes` lock + password sign-in for that address for `auth.lockout_minutes`. A correct + password clears the count. + + ## What the lock does and does not do + + - It is keyed by the address, not by an account: an address nobody + registered counts and locks the same way, so a lock tells no one which + addresses exist. + - While locked, the password is not even checked, and a correct one is + refused too: otherwise the lock would still answer "right" or "wrong". + - Only password sign-in is locked. An emailed login link and provider + sign-ins still work, so the owner of a locked account can still get in + while someone else is failing at the password. That is also why the lock + cannot be turned into a way to keep a player out. + + Counts live in the database, so they hold across every instance. + `Gamend.Retention` prunes rows whose window and lock have both run out. + """ + + import Ecto.Query + + alias Gamend.Accounts.LoginLockout + alias Gamend.Repo + + @doc """ + `:ok` when `email` may try a password, or `{:locked, seconds}` until it may. + """ + @spec check(String.t()) :: :ok | {:locked, pos_integer()} + def check(email) when is_binary(email) do + if enabled?() do + now = DateTime.utc_now(:second) + + locked_until = + Repo.one( + from(l in LoginLockout, + where: l.key_hash == ^key(email) and l.locked_until > ^now, + select: l.locked_until + ) + ) + + if locked_until, do: {:locked, max(DateTime.diff(locked_until, now), 1)}, else: :ok + else + :ok + end + end + + @doc """ + Count a failed password for `email`. Answers `{:locked, seconds}` when this + failure is the one that locks it, `:ok` otherwise. + """ + @spec record_failure(String.t()) :: :ok | {:locked, pos_integer()} + def record_failure(email) when is_binary(email) do + if enabled?() do + key = key(email) + + {:ok, result} = + Gamend.Lock.serialize("login_lockout", Base.encode16(key), fn -> count_failure(key) end) + + result + else + :ok + end + end + + @doc "Forget `email`'s failures and lift its lock: a correct password, or an admin." + @spec clear(String.t()) :: :ok + def clear(email) when is_binary(email) do + query = from(l in LoginLockout, where: l.key_hash == ^key(email)) + + # Checked first: this runs on every successful sign-in, and on SQLite even a + # DELETE that matches nothing takes the write lock. + if Repo.exists?(query), do: Repo.delete_all(query) + :ok + end + + @doc "Rows whose window and lock have both run out. For `Gamend.Retention`." + @spec expired_query() :: Ecto.Query.t() + def expired_query do + # A row is written on every failure, and a lock starts on the failure that + # set it, so an `updated_at` older than both the window and the lock means + # both are over. + minutes = max(window_minutes(), lockout_minutes()) + cutoff = DateTime.add(DateTime.utc_now(:second), -minutes, :minute) + + from(l in LoginLockout, where: l.updated_at < ^cutoff) + end + + # Inside `Lock.serialize/3`, so two failures at once cannot both read the + # same count. + defp count_failure(key) do + now = DateTime.utc_now(:second) + window_opened = DateTime.add(now, -window_minutes(), :minute) + + row = + Repo.one(from(l in LoginLockout, where: l.key_hash == ^key)) || %LoginLockout{key_hash: key} + + {failures, started} = + if row.window_started_at && DateTime.after?(row.window_started_at, window_opened), + do: {row.failures + 1, row.window_started_at}, + else: {1, now} + + # The failure that locks also starts a fresh count, so once the lock runs + # out the address gets the full number of attempts again. + {changes, result} = + if failures >= attempts() do + locked_until = DateTime.add(now, lockout_minutes(), :minute) + + {%{failures: 0, window_started_at: now, locked_until: locked_until}, + {:locked, DateTime.diff(locked_until, now)}} + else + {%{failures: failures, window_started_at: started}, :ok} + end + + # `force: true` stamps `updated_at` even when nothing else changed: the + # retention prune reads it. + row |> Ecto.Changeset.change(changes) |> Repo.insert_or_update!(force: true) + result + end + + defp key(email), do: :crypto.hash(:sha256, email |> String.trim() |> String.downcase()) + + defp enabled?, do: attempts() > 0 + defp attempts, do: Gamend.Settings.get(Gamend.Accounts, :lockout_attempts) + defp window_minutes, do: max(Gamend.Settings.get(Gamend.Accounts, :lockout_window_minutes), 1) + defp lockout_minutes, do: max(Gamend.Settings.get(Gamend.Accounts, :lockout_minutes), 1) +end diff --git a/apps/gamend_core/lib/gamend/accounts/registration.ex b/apps/gamend_core/lib/gamend/accounts/registration.ex index 1e37ad531..f9c7a6be5 100644 --- a/apps/gamend_core/lib/gamend/accounts/registration.ex +++ b/apps/gamend_core/lib/gamend/accounts/registration.ex @@ -9,6 +9,7 @@ defmodule Gamend.Accounts.Registration do import Ecto.Query, warn: false require Logger + alias Gamend.Accounts.ConfirmationMailer alias Gamend.Accounts.User alias Gamend.Accounts.UsernameGenerator alias Gamend.Accounts.UserNotifier @@ -98,13 +99,13 @@ defmodule Gamend.Accounts.Registration do end @doc """ - Register a user and send the confirmation email inside a DB transaction. + Register a user and queue its confirmation email. - The function accepts a `confirmation_url_fun` which must be a function of arity 1 - that receives the encoded token and returns the confirmation URL string. - - If sending the confirmation email fails the transaction is rolled back and - `{:error, reason}` is returned. On success it returns `{:ok, user}`. + `confirmation_url_fun` maps an encoded token to the confirmation URL. The + email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), + enqueued in the transaction that inserts the user: the call returns once + both are committed, without waiting on SMTP, and a failed send is retried + there. The first user becomes the admin and gets no email. """ @spec register_user_and_deliver(Types.user_registration_attrs(), (String.t() -> String.t())) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | term()} @@ -119,11 +120,11 @@ defmodule Gamend.Accounts.Registration do notifier \\ Gamend.Accounts.UserNotifier ) when is_function(confirmation_url_fun, 1) do - register_and_deliver(attrs, &User.email_changeset/2, confirmation_url_fun, notifier) + register_and_deliver(attrs, &User.email_changeset/3, confirmation_url_fun, notifier) end @doc """ - Register a user with an email and a password and send the confirmation + Register a user with an email and a password and queue the confirmation email, as `register_user_and_deliver/3` does for the browser form: how a game client signs up (`POST /api/v1/register`). """ @@ -138,7 +139,7 @@ defmodule Gamend.Accounts.Registration do notifier \\ Gamend.Accounts.UserNotifier ) when is_function(confirmation_url_fun, 1) do - register_and_deliver(attrs, &User.registration_changeset/2, confirmation_url_fun, notifier) + register_and_deliver(attrs, &User.registration_changeset/3, confirmation_url_fun, notifier) end defp register_and_deliver(attrs, base_changeset, confirmation_url_fun, notifier) do @@ -148,28 +149,37 @@ defmodule Gamend.Accounts.Registration do # Check if this is the first user and make them admin is_first_user = first_user?() - changeset_fun = fn attrs -> + build = fn attrs, opts -> %User{} - |> base_changeset.(attrs) + |> base_changeset.(attrs, opts) |> User.username_changeset(attrs) |> maybe_make_first_user_admin(is_first_user) |> maybe_deactivate_new_user(is_first_user) end + changeset_fun = &build.(&1, []) + + # The plugins' tentative user needs neither the password hash (Argon2id, + # ~24ms) nor the email-uniqueness query: the real changeset runs both. + # Unhashed, the plaintext would stay on it, so it is dropped. + tentative_fun = fn attrs -> + attrs + |> build.(hash_password: false, validate_unique: false) + |> Ecto.Changeset.delete_change(:password) + end + + # Two inserts and nothing slow: the confirmation email is a job, queued + # here so a committed account always has one, and sent after commit. transaction_fun = fn changeset -> - case Repo.insert(changeset) do - {:ok, %User{} = user} -> - case maybe_send_confirmation(user, is_first_user, notifier, confirmation_url_fun) do - :ok -> user - {:error, reason} -> Repo.rollback(reason) - end - - {:error, %Ecto.Changeset{} = changeset} -> - Repo.rollback(changeset) + with {:ok, %User{} = user} <- Repo.insert(changeset), + :ok <- queue_confirmation(user, is_first_user, confirmation_url_fun, notifier) do + user + else + {:error, reason} -> Repo.rollback(reason) end end - with {:ok, attrs} <- run_before_user_register(changeset_fun, attrs), + with {:ok, attrs} <- run_before_user_register(tentative_fun, attrs), {:ok, %User{} = user} <- transact_with_username_retry(changeset_fun, transaction_fun, attrs) do Accounts.invalidate_users_count_cache() @@ -182,17 +192,11 @@ defmodule Gamend.Accounts.Registration do end end - defp maybe_send_confirmation(_user, true, _notifier, _fun), do: :ok - - defp maybe_send_confirmation(user, false, notifier, confirmation_url_fun) do - {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") - Repo.insert!(user_token) + defp queue_confirmation(_user, true = _is_first_user, _url_fun, _notifier), do: :ok - case notifier.deliver_confirmation_instructions( - user, - confirmation_url_fun.(encoded_token) - ) do - {:ok, _} -> :ok + defp queue_confirmation(user, false, confirmation_url_fun, notifier) do + case user |> ConfirmationMailer.new_for(confirmation_url_fun, notifier) |> Oban.insert() do + {:ok, _job} -> :ok {:error, reason} -> {:error, reason} end end @@ -250,7 +254,7 @@ defmodule Gamend.Accounts.Registration do defp transact_with_username_retry(changeset_fun, transaction_fun, attrs, attempt \\ 1) do changeset = changeset_fun.(attrs) - case Repo.transaction(fn -> transaction_fun.(changeset) end) do + case Gamend.AfterCommit.transaction(fn -> transaction_fun.(changeset) end) do {:error, %Ecto.Changeset{} = changeset} = err -> case regenerate_username_attrs(attrs, changeset, attempt) do {:retry, attrs} -> @@ -359,7 +363,7 @@ defmodule Gamend.Accounts.Registration do end defp confirm_user_by_token_tx(%User{} = user) do - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> {:ok, confirmed_user} = confirm_user(user) Repo.delete_all( diff --git a/apps/gamend_core/lib/gamend/accounts/stale_presence_sweeper.ex b/apps/gamend_core/lib/gamend/accounts/stale_presence_sweeper.ex index 82985505c..75b5a2c20 100644 --- a/apps/gamend_core/lib/gamend/accounts/stale_presence_sweeper.ex +++ b/apps/gamend_core/lib/gamend/accounts/stale_presence_sweeper.ex @@ -9,11 +9,11 @@ defmodule Gamend.Accounts.StalePresenceSweeper do ## Configuration - config :gamend_core, Gamend.Accounts.StalePresenceSweeper, - interval_ms: 120_000, # how often to run the sweep (default 2 min) - stale_threshold_s: 300, # mark offline if last_seen > 5 min ago - enabled: true # set false to disable the sweep entirely + `interval_ms` and `stale_threshold_s` are settings (`GAMEND_PRESENCE_*`). + `enabled: false` in the app config turns the sweep off entirely (tests). + A connected socket refreshes `last_seen_at` on a heartbeat derived from the + threshold (`heartbeat_ms/0`), so a live player is never swept. """ use GenServer @@ -25,8 +25,27 @@ defmodule Gamend.Accounts.StalePresenceSweeper do alias Gamend.Accounts.User alias Gamend.Repo - @default_interval_ms 120_000 - @default_stale_threshold_s 300 + use Gamend.Settings.Provider, + app: :gamend_core, + group: :presence, + label: "Presence" + + setting(:interval_ms, :integer, + default: 120_000, + doc: "How often users still marked online after a crash are looked for, in ms." + ) + + setting(:stale_threshold_s, :integer, + default: 300, + doc: + "Mark a user offline once their last_seen_at is this many seconds old. Connected " <> + "sockets refresh it at three fifths of this, at most every 3 minutes." + ) + + # A connected socket refreshes `last_seen_at` at least this often, whatever + # the threshold: the abandoned-lobby reaper (15 minutes by default) reads the + # same column, so a long threshold must not slow the refresh down. + @max_heartbeat_ms :timer.minutes(3) # ── Public API ────────────────────────────────────────────────────────────── @@ -42,6 +61,19 @@ defmodule Gamend.Accounts.StalePresenceSweeper do Application.get_env(:gamend_core, __MODULE__, []) end + @doc """ + How often a connected socket refreshes `last_seen_at`: three fifths of + `stale_threshold_s`, so two refreshes fit before a user reads as stale, and + never less often than every 3 minutes. + """ + @spec heartbeat_ms() :: pos_integer() + def heartbeat_ms do + (threshold_s() * 600) |> min(@max_heartbeat_ms) |> max(1_000) + end + + defp interval_ms, do: max(Gamend.Settings.get(__MODULE__, :interval_ms), 1_000) + defp threshold_s, do: max(Gamend.Settings.get(__MODULE__, :stale_threshold_s), 1) + # ── GenServer callbacks ───────────────────────────────────────────────────── @impl true @@ -50,7 +82,7 @@ defmodule Gamend.Accounts.StalePresenceSweeper do enabled = Keyword.get(conf, :enabled, true) if enabled do - interval = Keyword.get(conf, :interval_ms, @default_interval_ms) + interval = interval_ms() # A hard stop skips every UserChannel.terminate/2, so the node comes # back with all previously connected users still is_online=true. Sweep # right away (as a message, so init never blocks the supervision tree @@ -71,11 +103,8 @@ defmodule Gamend.Accounts.StalePresenceSweeper do end def handle_info(:sweep, state) do - conf = config() - interval = Keyword.get(conf, :interval_ms, @default_interval_ms) - threshold_s = Keyword.get(conf, :stale_threshold_s, @default_stale_threshold_s) - - swept = do_sweep(threshold_s) + interval = interval_ms() + swept = do_sweep(threshold_s()) if swept > 0 do Logger.info("StalePresenceSweeper: marked #{swept} stale user(s) offline") diff --git a/apps/gamend_core/lib/gamend/accounts/stats.ex b/apps/gamend_core/lib/gamend/accounts/stats.ex index 8fbd01620..36a81b558 100644 --- a/apps/gamend_core/lib/gamend/accounts/stats.ex +++ b/apps/gamend_core/lib/gamend/accounts/stats.ex @@ -12,10 +12,6 @@ defmodule Gamend.Accounts.Stats do alias Gamend.Accounts.User alias Gamend.Repo - @stats_cache_ttl_ms 60_000 - - @users_count_cache_ttl_ms 60_000 - # Upper bound on cross-node staleness for cached user structs: explicit # invalidations propagate immediately via `Gamend.Cache.invalidate/1`, # and this TTL caps staleness if an invalidation broadcast is ever missed. @@ -24,7 +20,7 @@ defmodule Gamend.Accounts.Stats do Returns the total number of users. """ @spec count_users() :: non_neg_integer() - @decorate cacheable(key: {:accounts, :users_count}, opts: [ttl: @users_count_cache_ttl_ms]) + @decorate cacheable(key: {:accounts, :users_count}, opts: [ttl: Gamend.Cache.ttl()]) def count_users, do: Repo.aggregate(User, :count, :id) @doc """ @@ -51,7 +47,7 @@ defmodule Gamend.Accounts.Stats do key: {:accounts, :stats, Accounts.users_stats_cache_version(), :users_with_provider, provider_field}, - opts: [ttl: @stats_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp count_users_with_provider_cached(provider_field) do Repo.one( @@ -72,7 +68,7 @@ defmodule Gamend.Accounts.Stats do @decorate cacheable( key: {:accounts, :stats, Accounts.users_stats_cache_version(), :users_with_password}, - opts: [ttl: @stats_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp count_users_with_password_cached do Repo.one( @@ -119,7 +115,7 @@ defmodule Gamend.Accounts.Stats do players_in_parties: non_neg_integer() } def player_stats do - Gamend.Cache.cached({:accounts, :player_stats}, [ttl: @stats_cache_ttl_ms], fn -> + Gamend.Cache.cached({:accounts, :player_stats}, [ttl: Gamend.Cache.ttl()], fn -> total = count_users() online = count_users_online() diff --git a/apps/gamend_core/lib/gamend/accounts/user.ex b/apps/gamend_core/lib/gamend/accounts/user.ex index 6661dffda..09bbe1ea6 100644 --- a/apps/gamend_core/lib/gamend/accounts/user.ex +++ b/apps/gamend_core/lib/gamend/accounts/user.ex @@ -26,7 +26,8 @@ defmodule Gamend.Accounts.User do age_method: String.t() | nil, age_locked_at: DateTime.t() | nil, account_class: String.t() | nil, - grandfathered_at: DateTime.t() | nil + grandfathered_at: DateTime.t() | nil, + deletion_scheduled_at: DateTime.t() | nil } use Gamend.Schema import Ecto.Changeset @@ -71,6 +72,11 @@ defmodule Gamend.Accounts.User do field :account_class, :string, default: "unknown" field :grandfathered_at, :utc_datetime + # Set when the owner asked to delete the account and + # `auth.deletion_grace_days` makes that wait. See + # `Gamend.Accounts.request_deletion/1`. + field :deletion_scheduled_at, :utc_datetime + # membership via users.lobby_id (each user can be in one lobby) belongs_to :lobby, Gamend.Lobbies.Lobby diff --git a/apps/gamend_core/lib/gamend/accounts/user_notifier.ex b/apps/gamend_core/lib/gamend/accounts/user_notifier.ex index 011d3c27b..5e2474452 100644 --- a/apps/gamend_core/lib/gamend/accounts/user_notifier.ex +++ b/apps/gamend_core/lib/gamend/accounts/user_notifier.ex @@ -26,15 +26,35 @@ defmodule Gamend.Accounts.UserNotifier do # # Log every failure here: most callers ignore the return value, so a # rejected relay or unverified sender domain is otherwise invisible. - try do - case Mailer.deliver(email) do - {:ok, _metadata} -> {:ok, email} - other -> failed(subject, other) - end - rescue - e -> failed(subject, {:exception, e}) - catch - kind, reason -> failed(subject, {kind, reason}) + case deliver_bounded(email) do + {:ok, _metadata} -> {:ok, email} + other -> failed(subject, other) + end + end + + # gen_smtp waits up to 20 minutes for each reply from the relay, and that is + # not configurable: a hung relay hung whatever sent the mail (a magic-link + # request, an email change, a mail job) for that long. The send runs in a + # task given `mail.send_timeout_ms`; what it raises or exits is caught inside + # it, as the task is linked to the caller. + defp deliver_bounded(email) do + task = + Task.async(fn -> + try do + Mailer.deliver(email) + rescue + e -> {:exception, e} + catch + kind, reason -> {kind, reason} + end + end) + + timeout = Gamend.Settings.get(Gamend.Mail, :send_timeout_ms) + + case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do + {:ok, result} -> result + {:exit, reason} -> {:exit, reason} + nil -> {:error, :timeout} end end diff --git a/apps/gamend_core/lib/gamend/accounts/user_token.ex b/apps/gamend_core/lib/gamend/accounts/user_token.ex index 47c4973d3..2e547116d 100644 --- a/apps/gamend_core/lib/gamend/accounts/user_token.ex +++ b/apps/gamend_core/lib/gamend/accounts/user_token.ex @@ -14,12 +14,10 @@ defmodule Gamend.Accounts.UserToken do @hash_algorithm :sha256 @rand_size 32 - # It is very important to keep the magic link token expiry short, - # since someone with access to the email may take over the account. - @magic_link_validity_in_minutes 15 - @change_email_validity_in_days 7 - @confirm_validity_in_days 7 - @session_validity_in_days 14 + # Validity windows are `auth.*` settings on `Gamend.Accounts`, read on every + # check. The magic link is capped: whoever can read the email can take over + # the account while the link lives, so it must stay short. + @magic_link_max_minutes 60 schema "users_tokens" do field :token, :binary @@ -45,26 +43,44 @@ defmodule Gamend.Accounts.UserToken do @doc """ Query selecting token rows that are past their own context's validity window. - Each context expires on a different clock (session 14d, magic link 15min, - email change and confirmation 7d), and those windows live here — so retention inverts the - same predicate the verify queries use instead of guessing a single age. - Contexts this module does not know are never selected. + Each context expires on a different clock (by default session 14d, magic + link 15min, email change and confirmation 7d), and those windows live here — + so retention inverts the same predicate the verify queries use instead of + guessing a single age. Contexts this module does not know are never selected. """ @spec expired_query() :: Ecto.Query.t() def expired_query do + session_days = session_validity_in_days() + magic_link_minutes = magic_link_validity_in_minutes() + change_email_days = change_email_validity_in_days() + confirm_days = confirm_validity_in_days() + from t in __MODULE__, where: - (t.context == "session" and t.inserted_at < ago(@session_validity_in_days, "day")) or - (t.context == "login" and - t.inserted_at < ago(@magic_link_validity_in_minutes, "minute")) or - (like(t.context, "change:%") and - t.inserted_at < ago(@change_email_validity_in_days, "day")) or - (t.context == "confirm" and t.inserted_at < ago(@confirm_validity_in_days, "day")) + (t.context == "session" and t.inserted_at < ago(^session_days, "day")) or + (t.context == "login" and t.inserted_at < ago(^magic_link_minutes, "minute")) or + (like(t.context, "change:%") and t.inserted_at < ago(^change_email_days, "day")) or + (t.context == "confirm" and t.inserted_at < ago(^confirm_days, "day")) end - @doc "How long an email confirmation link stays valid, in days." + @doc "How long a browser session lasts, in days (`auth.session_days`)." + @spec session_validity_in_days() :: pos_integer() + def session_validity_in_days, do: setting_at_least_one(:session_days) + + @doc "How long a magic link stays valid, in minutes (`auth.magic_link_minutes`, at most 60)." + @spec magic_link_validity_in_minutes() :: pos_integer() + def magic_link_validity_in_minutes, + do: min(setting_at_least_one(:magic_link_minutes), @magic_link_max_minutes) + + @doc "How long an email-change link stays valid, in days (`auth.change_email_days`)." + @spec change_email_validity_in_days() :: pos_integer() + def change_email_validity_in_days, do: setting_at_least_one(:change_email_days) + + @doc "How long an email confirmation link stays valid, in days (`auth.confirm_email_days`)." @spec confirm_validity_in_days() :: pos_integer() - def confirm_validity_in_days, do: @confirm_validity_in_days + def confirm_validity_in_days, do: setting_at_least_one(:confirm_email_days) + + defp setting_at_least_one(key), do: max(Gamend.Settings.get(Gamend.Accounts, key), 1) @doc """ Generates a token that will be stored in a signed place, @@ -97,13 +113,15 @@ defmodule Gamend.Accounts.UserToken do The query returns the user found by the token, if any, along with the token's creation time. The token is valid if it matches the value in the database and it has - not expired (after @session_validity_in_days). + not expired (after `session_validity_in_days/0`). """ def verify_session_token_query(token) do + days = session_validity_in_days() + query = from token in by_token_and_context_query(token, "session"), join: user in assoc(token, :user), - where: token.inserted_at > ago(@session_validity_in_days, "day"), + where: token.inserted_at > ago(^days, "day"), select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at} {:ok, query} @@ -146,17 +164,19 @@ defmodule Gamend.Accounts.UserToken do The given token is valid if it matches its hashed counterpart in the database. This function also checks if the token is being used within - 15 minutes. The context of a magic link token is always "login". + `magic_link_validity_in_minutes/0`. The context of a magic link token is + always "login". """ def verify_magic_link_token_query(token) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + minutes = magic_link_validity_in_minutes() query = from token in by_token_and_context_query(hashed_token, "login"), join: user in assoc(token, :user), - where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), + where: token.inserted_at > ago(^minutes, "minute"), where: token.sent_to == user.email, select: {user, token} @@ -175,17 +195,18 @@ defmodule Gamend.Accounts.UserToken do This is used to validate requests to change the user email. The given token is valid if it matches its hashed counterpart in the - database and if it has not expired (after @change_email_validity_in_days). + database and if it has not expired (after `change_email_validity_in_days/0`). The context must always start with "change:". """ def verify_change_email_token_query(token, "change:" <> _ = context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = change_email_validity_in_days() query = from token in by_token_and_context_query(hashed_token, context), - where: token.inserted_at > ago(@change_email_validity_in_days, "day") + where: token.inserted_at > ago(^days, "day") {:ok, query} diff --git a/apps/gamend_core/lib/gamend/after_commit.ex b/apps/gamend_core/lib/gamend/after_commit.ex new file mode 100644 index 000000000..ca4d419ad --- /dev/null +++ b/apps/gamend_core/lib/gamend/after_commit.ex @@ -0,0 +1,116 @@ +defmodule Gamend.AfterCommit do + @moduledoc """ + Side effects that wait for the enclosing transaction to commit. + + A broadcast, a hook dispatch or a spawned task inside a transaction runs + while the transaction holds the database, which on SQLite (a single + connection, `BEGIN IMMEDIATE`) is every other request's database too. It is + also observed before the write is visible, and survives a rollback of the + write it announces. `defer/1` queues the effect instead, and the queue runs + once the outermost `transaction/2` (or `Gamend.Lock.serialize/3`) has + committed and released its lock. A rollback or an exception drops it. + + Outside such a scope `defer/1` runs the effect at once, so a helper can + always defer and stay correct whether or not its caller holds a + transaction. Inside a transaction opened with a bare `Repo.transaction/2` + there is no commit to wait for, and it also runs at once, as before. + + Queued effects run in order, in the calling process, after the lock is + released. The write is committed by then, so one that raises or exits is + logged and does not stop the rest or fail the caller. An effect run at once + behaves as a plain call. + """ + + require Logger + + alias Gamend.Repo + + @queue {__MODULE__, :queue} + + @doc """ + `Repo.transaction/2` (a function or an `Ecto.Multi`), with `defer/1` inside + it queued until it commits. Every transaction in core goes through here. + """ + @spec transaction((-> term()) | Ecto.Multi.t(), keyword()) :: + {:ok, term()} | {:error, term()} | {:error, term(), term(), map()} + def transaction(fun_or_multi, opts \\ []) do + collect(fn -> Repo.transaction(fun_or_multi, opts) end) + end + + @doc "`Repo.transact/2`, with `defer/1` inside it queued until it commits." + @spec transact((-> {:ok, term()} | {:error, term()}), keyword()) :: + {:ok, term()} | {:error, term()} + def transact(fun, opts \\ []) when is_function(fun, 0) do + collect(fn -> Repo.transact(fun, opts) end) + end + + @doc """ + Runs `body`, which opens a transaction, as the scope `defer/1` queues into: + the queue runs when `body` returns `{:ok, _}` and is dropped otherwise. For + wrappers that take a lock around the transaction (`Gamend.Lock.serialize/3`), + so the effects run after the lock is released as well. + + Inside an outer scope it only runs `body`; the outer scope decides. + """ + @spec collect((-> result)) :: result when result: term() + def collect(body) when is_function(body, 0) do + cond do + Process.get(@queue) != nil -> body.() + # A bare `Repo.transaction/2` is open around us: its commit is not ours + # to see, so effects cannot wait for it. + Repo.in_transaction?() -> body.() + true -> own_scope(body) + end + end + + @doc "Runs `fun` once the enclosing scope commits, or now when there is none." + @spec defer((-> any())) :: :ok + def defer(fun) when is_function(fun, 0) do + case Process.get(@queue) do + nil -> + fun.() + :ok + + queue -> + Process.put(@queue, [fun | queue]) + :ok + end + end + + @doc "Whether `defer/1` would queue rather than run." + @spec deferring?() :: boolean() + def deferring?, do: Process.get(@queue) != nil + + defp own_scope(body) do + Process.put(@queue, []) + + result = + try do + body.() + rescue + e -> + Process.delete(@queue) + reraise e, __STACKTRACE__ + catch + kind, reason -> + Process.delete(@queue) + :erlang.raise(kind, reason, __STACKTRACE__) + end + + effects = @queue |> Process.delete() |> Enum.reverse() + if match?({:ok, _}, result), do: Enum.each(effects, &run_committed/1) + result + end + + defp run_committed(fun) do + fun.() + rescue + e -> + Logger.error("after-commit effect failed: " <> Exception.format(:error, e, __STACKTRACE__)) + catch + kind, reason -> + Logger.error( + "after-commit effect failed: " <> Exception.format(kind, reason, __STACKTRACE__) + ) + end +end diff --git a/apps/gamend_core/lib/gamend/async.ex b/apps/gamend_core/lib/gamend/async.ex index 98167fcd8..f1bdece37 100644 --- a/apps/gamend_core/lib/gamend/async.ex +++ b/apps/gamend_core/lib/gamend/async.ex @@ -27,12 +27,18 @@ defmodule Gamend.Async do def run(fun) when is_function(fun, 0) do wrapped = wrap(fun) - if inline?() do - wrapped.() - :ok - else - spawn_task(wrapped) - end + # Inside a transaction the task would start while it holds the database, + # see the write before it is committed (or one a rollback then erases), and + # on SQLite wait for the very connection its spawner holds. It starts once + # the transaction commits instead (`Gamend.AfterCommit`). + Gamend.AfterCommit.defer(fn -> + if inline?() do + wrapped.() + :ok + else + spawn_task(wrapped) + end + end) end # Tests set `:async_inline` so side effects land before assertions run, and so diff --git a/apps/gamend_core/lib/gamend/broadcast.ex b/apps/gamend_core/lib/gamend/broadcast.ex index 771017c95..ee1f7e115 100644 --- a/apps/gamend_core/lib/gamend/broadcast.ex +++ b/apps/gamend_core/lib/gamend/broadcast.ex @@ -25,6 +25,24 @@ defmodule Gamend.Broadcast do """ @spec best_effort(String.t(), term(), String.t() | nil) :: :ok def best_effort(topic, message, label \\ nil) do + Gamend.AfterCommit.defer(fn -> send_best_effort(topic, message, label) end) + end + + @doc """ + Broadcasts `message` on `topic` through `Gamend.PubSub` once the enclosing + transaction commits (`Gamend.AfterCommit`), or now outside one. + + Subscribers then never see a write before it is visible, nor one a rollback + erases, and the broadcast never runs while a transaction holds the database. + Every broadcast in core goes through here; the cache's own invalidation + messages are the exception (`Gamend.Cache`). + """ + @spec publish(String.t(), term()) :: :ok + def publish(topic, message) do + Gamend.AfterCommit.defer(fn -> Phoenix.PubSub.broadcast(Gamend.PubSub, topic, message) end) + end + + defp send_best_effort(topic, message, label) do Phoenix.PubSub.broadcast(Gamend.PubSub, topic, message) :ok rescue diff --git a/apps/gamend_core/lib/gamend/cache.ex b/apps/gamend_core/lib/gamend/cache.ex index 8428365fe..7e3a69a35 100644 --- a/apps/gamend_core/lib/gamend/cache.ex +++ b/apps/gamend_core/lib/gamend/cache.ex @@ -14,6 +14,14 @@ defmodule Gamend.Cache do otp_app: :gamend_core, adapter: Nebulex.Adapters.Multilevel + @doc """ + How long a cached entity is kept, in milliseconds (`cache.ttl_ms`). Every + entity cache uses it; the few entries with a deliberate lifetime of their own + (leaderboard records, analytics) do not. + """ + @spec ttl() :: pos_integer() + def ttl, do: max(Gamend.Settings.get(Gamend.Cache.Settings, :ttl_ms), 1) + @doc """ Cache-through helper: returns the cached value for `key`, or computes and caches the result of `fun`. @@ -50,6 +58,11 @@ defmodule Gamend.Cache do """ @spec invalidate(term()) :: :ok def invalidate(key) do + evict(key) + again_after_commit(fn -> evict(key) end) + end + + defp evict(key) do _ = delete(key) Phoenix.PubSub.broadcast( @@ -57,7 +70,15 @@ defmodule Gamend.Cache do @invalidation_topic, {:cache_invalidate, key, Node.self()} ) + end + # Inside a transaction, once more after it commits (`Gamend.AfterCommit`). + # Until the commit a concurrent read still sees the old row and can cache it + # back, on any node, where it would stay until the TTL. The first eviction + # stays immediate so the transaction's own reads through the cache see its + # writes. + defp again_after_commit(fun) do + if Gamend.AfterCommit.deferring?(), do: Gamend.AfterCommit.defer(fun) :ok end @@ -73,6 +94,11 @@ defmodule Gamend.Cache do """ @spec bump_version(term()) :: :ok def bump_version(key) do + bump(key) + again_after_commit(fn -> bump(key) end) + end + + defp bump(key) do # Nebulex returns {:ok, counter} | {:error, t}; no caller wants either. _ = incr(key, 1, default: 1) @@ -81,8 +107,6 @@ defmodule Gamend.Cache do @invalidation_topic, {:cache_bump_version, key, Node.self()} ) - - :ok end @doc "PubSub topic that `invalidate/1` broadcasts on." diff --git a/apps/gamend_core/lib/gamend/cache/settings.ex b/apps/gamend_core/lib/gamend/cache/settings.ex index c9eaec614..939eb5274 100644 --- a/apps/gamend_core/lib/gamend/cache/settings.ex +++ b/apps/gamend_core/lib/gamend/cache/settings.ex @@ -33,4 +33,25 @@ defmodule Gamend.Cache.Settings do ) setting(:redis_pool_size, :integer, default: 10) + + # Both bound each node's local cache (L1, and a partitioned L2's local + # primary). Whichever is reached first evicts the oldest generation. + setting(:max_entries, :integer, + default: 1_000_000, + doc: "Most entries each node's local cache holds." + ) + + setting(:max_memory_mb, :integer, + default: 500, + doc: + "Most memory each node's local cache may use, in MB. Lower it on a small machine: " <> + "the default alone is most of a 512 MB instance." + ) + + setting(:ttl_ms, :integer, + default: 60_000, + doc: + "How long a cached entity (user, lobby, party, group, KV entry...) is kept, in ms. " <> + "On a cluster it bounds how stale a node can be when an invalidation is missed." + ) end diff --git a/apps/gamend_core/lib/gamend/chat.ex b/apps/gamend_core/lib/gamend/chat.ex index 5b3baf513..20a14efc6 100644 --- a/apps/gamend_core/lib/gamend/chat.ex +++ b/apps/gamend_core/lib/gamend/chat.ex @@ -40,8 +40,6 @@ defmodule Gamend.Chat do # Cache helpers # --------------------------------------------------------------------------- - @chat_cache_ttl_ms 60_000 - defp chat_version(chat_type, chat_ref_id) do Gamend.Cache.get!({:chat, :version, chat_type, chat_ref_id}) || 1 end @@ -110,12 +108,12 @@ defmodule Gamend.Chat do defp broadcast_chat(chat_type, chat_ref_id, sender_id, event) do topic = chat_topic(chat_type, chat_ref_id, sender_id) - Phoenix.PubSub.broadcast(Gamend.PubSub, topic, event) + Gamend.Broadcast.publish(topic, event) # For friend DMs, also broadcast to the recipient's user topic so the # UserChannel can forward the message without subscribing to every pair. if chat_type == "friend" do - Phoenix.PubSub.broadcast(Gamend.PubSub, "user:#{chat_ref_id}", event) + Gamend.Broadcast.publish("user:#{chat_ref_id}", event) end end @@ -532,7 +530,7 @@ defmodule Gamend.Chat do key: {:chat, :list, chat_version(chat_type, chat_ref_id), chat_type, chat_ref_id, page, page_size}, - opts: [ttl: @chat_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp do_list_messages(chat_type, chat_ref_id, page, page_size, _offset) do base_query(chat_type, chat_ref_id) @@ -863,7 +861,7 @@ defmodule Gamend.Chat do @decorate cacheable( key: {:chat, :message, message_row_version(), id}, match: &(&1 != nil), - opts: [ttl: @chat_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_message(id) do Repo.get(Message, id) diff --git a/apps/gamend_core/lib/gamend/content/markdown.ex b/apps/gamend_core/lib/gamend/content/markdown.ex index 4350838d2..2634d6c38 100644 --- a/apps/gamend_core/lib/gamend/content/markdown.ex +++ b/apps/gamend_core/lib/gamend/content/markdown.ex @@ -49,6 +49,9 @@ defmodule Gamend.Content.Markdown do * `:base_path` — the route prefix a `.md` link rewrites to; `nil` leaves such links untouched * `:slug` — the document's slug, which relative links resolve against + * `:index` — the document is a folder's `index.md`, whose slug *is* its + folder, so its links resolve against the slug rather than its parent. + `render_file/2` sets it from the file name. * `:id` — a stable prefix for element ids (mermaid diagrams need one) """ @type opt :: @@ -57,6 +60,7 @@ defmodule Gamend.Content.Markdown do | {:dir, String.t()} | {:base_path, String.t() | nil} | {:slug, String.t() | nil} + | {:index, boolean()} | {:id, String.t()} @admonitions ~w(note tip info warning danger caution important) @@ -74,6 +78,8 @@ defmodule Gamend.Content.Markdown do @doc "Render a file, or `nil` when it cannot be read or parsed." @spec render_file(Path.t(), [opt()]) :: String.t() | nil def render_file(path, opts \\ []) do + opts = Keyword.put_new(opts, :index, Path.rootname(Path.basename(path)) == "index") + case File.read(path) do {:ok, content} -> case render(content, opts) do @@ -332,15 +338,23 @@ defmodule Gamend.Content.Markdown do # `[Principles](./principles.md)` and `(../reference/index.md#anchor)` are # how a guide points at its neighbours, and how an editor previews them. - # Resolved against the document's own slug — order prefixes stripped, an - # `index` file meaning its folder — and rewritten to the route. + # Resolved against the document's own folder — order prefixes stripped, an + # `index` file meaning its folder — and rewritten to the route. A page's + # folder is its slug's parent; an index page's slug already names its + # folder, so taking the parent there sent `builds.md` in `forge/index.md` + # to `/docs/builds`. defp rewrite_links(document, opts) do case Keyword.get(opts, :base_path) do nil -> document base -> - slug_dir = (Keyword.get(opts, :slug) || "") |> Path.dirname() |> normalize_dir() + slug = Keyword.get(opts, :slug) || "" + + slug_dir = + if Keyword.get(opts, :index, false), + do: slug, + else: slug |> Path.dirname() |> normalize_dir() MDEx.Document.update_nodes(document, MDEx.Link, fn %{url: url} = link -> case rewrite_md_link(url, slug_dir, base) do diff --git a/apps/gamend_core/lib/gamend/economy.ex b/apps/gamend_core/lib/gamend/economy.ex index 92a22e02b..2c6f979d5 100644 --- a/apps/gamend_core/lib/gamend/economy.ex +++ b/apps/gamend_core/lib/gamend/economy.ex @@ -163,8 +163,7 @@ defmodule Gamend.Economy do defp topic(user_id), do: @topic_prefix <> user_id defp broadcast_wallet(user_id, currency, balance, delta) do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( topic(user_id), {:wallet_updated, %{currency: currency, balance: balance, delta: delta}} ) diff --git a/apps/gamend_core/lib/gamend/friends.ex b/apps/gamend_core/lib/gamend/friends.ex index bfde9cf4c..65c8de852 100644 --- a/apps/gamend_core/lib/gamend/friends.ex +++ b/apps/gamend_core/lib/gamend/friends.ex @@ -38,9 +38,6 @@ defmodule Gamend.Friends do alias Gamend.Types @friends_topic "friends" - @friends_cache_ttl_ms 60_000 - @friendships_cache_ttl_ms 60_000 - @type user_id :: Ecto.UUID.t() defp friends_cache_version(user_id) when is_binary(user_id) do @@ -75,7 +72,7 @@ defmodule Gamend.Friends do key: {:friends, :pair, friends_cache_version(requester_id), friends_cache_version(target_id), requester_id, target_id}, - opts: [ttl: @friends_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp get_by_pair_cached(requester_id, target_id) when is_binary(requester_id) and is_binary(target_id) do @@ -94,7 +91,7 @@ defmodule Gamend.Friends do defp broadcast_user(user_id, event) when is_binary(user_id) do # keep existing PubSub behavior (server-side consumers) - Phoenix.PubSub.broadcast(Gamend.PubSub, "friends:user:#{user_id}", event) + Gamend.Broadcast.publish("friends:user:#{user_id}", event) # also push a channel-friendly version to the per-user Phoenix channel # so clients joined to "user:" receive realtime updates via sockets. @@ -110,8 +107,7 @@ defmodule Gamend.Friends do # Broadcast to the user channel without depending on the web app. topic = "user:#{user_id}" - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( topic, %Phoenix.Socket.Broadcast{topic: topic, event: Atom.to_string(name), payload: payload} ) @@ -122,7 +118,7 @@ defmodule Gamend.Friends do end defp broadcast_all(event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, @friends_topic, event) + Gamend.Broadcast.publish(@friends_topic, event) end # ── Friend notifications ──────────────────────────────────────────────── @@ -359,7 +355,7 @@ defmodule Gamend.Friends do @doc "Accept a friend request (only the target may accept). Returns {:ok, friendship}." @spec accept_friend_request(Ecto.UUID.t(), User.t()) :: {:ok, Friendship.t()} | {:error, term()} def accept_friend_request(friendship_id, %User{id: user_id}) when is_binary(friendship_id) do - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> with %Friendship{} = f <- get_friendship(friendship_id), true <- f.target_id == user_id, true <- f.status == "pending", @@ -381,7 +377,9 @@ defmodule Gamend.Friends do broadcast_user(accepted.requester_id, {:friend_accepted, accepted}) broadcast_user(accepted.target_id, {:friend_accepted, accepted}) broadcast_all({:friend_accepted, accepted}) - notify_friend_accepted(accepted) + # Its insert, push fan-out and `before_push_send` hook are not the + # acceptance: they wait for the commit (`Gamend.AfterCommit`). + Gamend.AfterCommit.defer(fn -> notify_friend_accepted(accepted) end) accepted else @@ -518,7 +516,7 @@ defmodule Gamend.Friends do end defp do_block_user(blocker_id, blocked_id) do - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> # Drop the reverse row first: the pair is unique per direction, so a # reverse row would otherwise survive alongside the canonical block. Repo.delete_all( @@ -1086,7 +1084,7 @@ defmodule Gamend.Friends do @spec get_friendship!(Ecto.UUID.t()) :: Friendship.t() @decorate cacheable( key: {:friends, :friendship, friendship_cache_version(id), id}, - opts: [ttl: @friendships_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_friendship!(id), do: Repo.get_uuid!(Friendship, id) @@ -1095,7 +1093,7 @@ defmodule Gamend.Friends do @decorate cacheable( key: {:friends, :friendship, friendship_cache_version(id), id}, match: &cache_match/1, - opts: [ttl: @friendships_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_friendship(id), do: Repo.get_uuid(Friendship, id) diff --git a/apps/gamend_core/lib/gamend/groups.ex b/apps/gamend_core/lib/gamend/groups.ex index 6e69f324c..f8363c13b 100644 --- a/apps/gamend_core/lib/gamend/groups.ex +++ b/apps/gamend_core/lib/gamend/groups.ex @@ -85,11 +85,11 @@ defmodule Gamend.Groups do end defp broadcast_groups(event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, @groups_topic, event) + Gamend.Broadcast.publish(@groups_topic, event) end defp broadcast_group(group_id, event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "group:#{group_id}", event) + Gamend.Broadcast.publish("group:#{group_id}", event) end @doc "Broadcast a presence event (e.g. member_online, member_updated) to a group topic." @@ -109,8 +109,6 @@ defmodule Gamend.Groups do # Cache helpers # --------------------------------------------------------------------------- - @group_cache_ttl_ms 60_000 - defp group_cache_version(group_id), do: Shared.group_cache_version(group_id) defp invalidate_group_cache(group_id), do: Shared.invalidate_group_cache(group_id) @@ -132,7 +130,7 @@ defmodule Gamend.Groups do @decorate cacheable( key: {:groups, :get, group_cache_version(id), id}, match: &cache_match/1, - opts: [ttl: @group_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_group(id), do: Repo.get_uuid(Group, id) @@ -140,7 +138,7 @@ defmodule Gamend.Groups do @spec get_group!(Ecto.UUID.t()) :: Group.t() @decorate cacheable( key: {:groups, :get, group_cache_version(id), id}, - opts: [ttl: @group_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_group!(id), do: Repo.get_uuid!(Group, id) @@ -480,7 +478,7 @@ defmodule Gamend.Groups do role: "admin" }) end) - |> Repo.transaction() + |> Gamend.AfterCommit.transaction() |> case do {:ok, %{group: group}} -> _ = invalidate_group_cache(group.id) @@ -685,7 +683,7 @@ defmodule Gamend.Groups do from(m in GroupMember, where: m.user_id == ^user_id) |> Repo.all() - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> Enum.each(memberships, fn member -> group_id = member.group_id @@ -744,62 +742,38 @@ defmodule Gamend.Groups do @doc "Leave a group." @spec leave_group(Ecto.UUID.t(), Ecto.UUID.t()) :: {:ok, GroupMember.t()} | {:error, atom()} - @leave_effects_key {__MODULE__, :leave_effects} - def leave_group(user_id, group_id) when is_binary(user_id) and is_binary(group_id) do - # Effects are collected during the transaction and run after it commits. - # - # `do_leave/3` broadcast `member_left` and `maybe_delete_empty_group/1` - # broadcast `group_deleted` from *inside* the open transaction, and the - # cache-version bump went out through `Gamend.Async.run` — so a subscriber - # that re-read on the event could see the pre-commit state, or cache rows - # that were about to be rolled back under the new version. - result = - Lock.serialize(:group, group_id, fn -> - Process.put(@leave_effects_key, []) - - case get_membership(group_id, user_id) do - nil -> - Repo.rollback(:not_member) - - member -> - # If leaving user is admin, check if they're the last admin - outcome = - if member.role == "admin" do - maybe_transfer_admin_before_leave(member, group_id, user_id) - else - do_leave(member, group_id, user_id) - end - - case outcome do - {:ok, deleted} -> {deleted, Process.get(@leave_effects_key, [])} - {:error, reason} -> Repo.rollback(reason) + # `do_leave/3`'s `member_left`, `maybe_delete_empty_group/1`'s + # `group_deleted` and the cache-version bump wait for the commit: a + # subscriber that re-read on the event could otherwise see the pre-commit + # state, or cache rows about to be rolled back under the new version. + Lock.serialize(:group, group_id, fn -> + case get_membership(group_id, user_id) do + nil -> + Repo.rollback(:not_member) + + member -> + # If leaving user is admin, check if they're the last admin + outcome = + if member.role == "admin" do + maybe_transfer_admin_before_leave(member, group_id, user_id) + else + do_leave(member, group_id, user_id) end - end - end) - - Process.delete(@leave_effects_key) - - case result do - {:ok, {deleted, effects}} -> - effects |> Enum.reverse() |> Enum.each(& &1.()) - {:ok, deleted} - {:error, reason} -> - {:error, reason} - end + case outcome do + {:ok, deleted} -> deleted + {:error, reason} -> Repo.rollback(reason) + end + end + end) end - # Queue an effect to run once `leave_group/2`'s transaction has committed. - # Outside that transaction there is nothing to wait for, so it runs now. + # Once the enclosing transaction commits (`Gamend.AfterCommit`): leaving a + # group, and account deletion's sweep of every group, both run in one. defp after_leave_commit(fun) when is_function(fun, 0) do - case Process.get(@leave_effects_key) do - nil -> fun.() - queued -> Process.put(@leave_effects_key, [fun | queued]) - end - - :ok + Gamend.AfterCommit.defer(fun) end # Whether removing `target_id`'s admin rights would leave the group with none. @@ -1235,8 +1209,7 @@ defmodule Gamend.Groups do {:ok, notification} -> Gamend.Notifications.invalidate_notifications_cache(recipient_id) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "notifications:user:#{recipient_id}", {:notification_created, notification} ) diff --git a/apps/gamend_core/lib/gamend/groups/invites.ex b/apps/gamend_core/lib/gamend/groups/invites.ex index 9566f1d53..db38e2579 100644 --- a/apps/gamend_core/lib/gamend/groups/invites.ex +++ b/apps/gamend_core/lib/gamend/groups/invites.ex @@ -18,13 +18,11 @@ defmodule Gamend.Groups.Invites do alias Gamend.Groups.Shared alias Gamend.Repo - @invite_cache_ttl_ms 60_000 - @doc "Count pending invitations for a user." @spec count_invitations(Ecto.UUID.t()) :: non_neg_integer() @decorate cacheable( key: {:group_invites, :count, Shared.invite_cache_version(user_id), user_id}, - opts: [ttl: @invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def count_invitations(user_id) when is_binary(user_id) do import Ecto.Query @@ -41,7 +39,7 @@ defmodule Gamend.Groups.Invites do @spec count_sent_invitations(Ecto.UUID.t()) :: non_neg_integer() @decorate cacheable( key: {:group_invites, :count_sent, Shared.invite_cache_version(user_id), user_id}, - opts: [ttl: @invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def count_sent_invitations(user_id) when is_binary(user_id) do import Ecto.Query @@ -280,8 +278,7 @@ defmodule Gamend.Groups.Invites do ) # Real-time PubSub so the sender's UI updates immediately - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{invite.sender_id}", {:group_invite_declined, %{group_id: group_id, user_id: user_id, reason: "full"}} ) @@ -320,8 +317,7 @@ defmodule Gamend.Groups.Invites do ) # Broadcast so the sender's LiveView refreshes - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{invite.sender_id}", {:group_invite_accepted, %{group_id: group_id}} ) @@ -350,7 +346,7 @@ defmodule Gamend.Groups.Invites do key: {:group_invites, :list, Shared.invite_cache_version(user_id), user_id, page, page_size}, - opts: [ttl: @invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp do_list_invitations(user_id, page, page_size) do import Ecto.Query @@ -384,7 +380,7 @@ defmodule Gamend.Groups.Invites do key: {:group_invites, :list_sent, Shared.invite_cache_version(user_id), user_id, page, page_size}, - opts: [ttl: @invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp do_list_sent_invitations(user_id, page, page_size) do import Ecto.Query @@ -490,8 +486,7 @@ defmodule Gamend.Groups.Invites do ) # Notify the sender via PubSub - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{invite.sender_id}", {:group_invite_declined, %{group_id: invite.group_id, user_id: user_id}} ) diff --git a/apps/gamend_core/lib/gamend/groups/join_requests.ex b/apps/gamend_core/lib/gamend/groups/join_requests.ex index e6ac64c61..850c7d2f6 100644 --- a/apps/gamend_core/lib/gamend/groups/join_requests.ex +++ b/apps/gamend_core/lib/gamend/groups/join_requests.ex @@ -196,7 +196,7 @@ defmodule Gamend.Groups.JoinRequests do role: "member" }) end) - |> Repo.transaction() + |> Gamend.AfterCommit.transaction() |> case do {:ok, %{membership: member}} -> _ = Shared.invalidate_group_cache(group_id) @@ -224,8 +224,7 @@ defmodule Gamend.Groups.JoinRequests do } ) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{user_id}", {:group_join_request_approved, %{group_id: group_id}} ) @@ -287,8 +286,7 @@ defmodule Gamend.Groups.JoinRequests do } ) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{updated.user_id}", {:group_join_request_rejected, %{group_id: group_id}} ) diff --git a/apps/gamend_core/lib/gamend/groups/shared.ex b/apps/gamend_core/lib/gamend/groups/shared.ex index 5d253f426..b4e95e25d 100644 --- a/apps/gamend_core/lib/gamend/groups/shared.ex +++ b/apps/gamend_core/lib/gamend/groups/shared.ex @@ -14,7 +14,7 @@ defmodule Gamend.Groups.Shared do @doc false def broadcast_group(group_id, event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "group:#{group_id}", event) + Gamend.Broadcast.publish("group:#{group_id}", event) end # -- Group cache (version-based, keyed by group_id) -- @@ -105,8 +105,7 @@ defmodule Gamend.Groups.Shared do } ) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{sender_id}", {:group_invite_accepted, %{group_id: group_id}} ) @@ -137,8 +136,7 @@ defmodule Gamend.Groups.Shared do # Broadcast a group_deleted event to each user who had a pending invite. def notify_invite_users_group_deleted(user_ids, group) do for uid <- user_ids do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{uid}", {:group_invite_cancelled, %{group_id: group.id, group_title: group.title}} ) @@ -175,25 +173,37 @@ defmodule Gamend.Groups.Shared do end end - # Shared helper: acquire advisory lock, check capacity, run hook, insert member. + # Shared helper: check capacity, run the hook, then lock, re-check and insert. + # + # The hook runs before the lock, as join-request approval already does: it + # may take up to its timeout, and inside the lock it held the group and, on + # SQLite, the only database connection. The capacity checks run first too, + # so a full group is refused without calling it, and again under the lock, + # where a concurrent join could have taken the last place. @doc false def do_add_group_member(user_id, group_id, group, source) do - Lock.serialize(:group, group_id, fn -> - if Groups.count_group_members(group_id) >= group.max_members do - Repo.rollback(:full) - end + with :ok <- check_room(user_id, group_id, group), + :ok <- run_before_group_join_hook(user_id, group, %{"source" => source}) do + Lock.serialize(:group, group_id, fn -> + case check_room(user_id, group_id, group) do + :ok -> insert_group_member(group_id, user_id) + {:error, reason} -> Repo.rollback(reason) + end + end) + end + end - max_groups = Gamend.Limits.get(:max_groups_per_user) + defp check_room(user_id, group_id, group) do + cond do + Groups.count_group_members(group_id) >= group.max_members -> + {:error, :full} - if Groups.count_user_group_memberships(user_id) >= max_groups do - Repo.rollback(:too_many_groups) - end + Groups.count_user_group_memberships(user_id) >= Gamend.Limits.get(:max_groups_per_user) -> + {:error, :too_many_groups} - case run_before_group_join_hook(user_id, group, %{"source" => source}) do - :ok -> insert_group_member(group_id, user_id) - {:error, reason} -> Repo.rollback(reason) - end - end) + true -> + :ok + end end defp insert_group_member(group_id, user_id) do diff --git a/apps/gamend_core/lib/gamend/hooks.ex b/apps/gamend_core/lib/gamend/hooks.ex index b3cea9a8d..feb3536d1 100644 --- a/apps/gamend_core/lib/gamend/hooks.ex +++ b/apps/gamend_core/lib/gamend/hooks.ex @@ -666,13 +666,19 @@ defmodule Gamend.Hooks do ]) end + # The hooks module leads, a host app's own modules follow, plugins last. + # `config :gamend_core, :host_hook_modules, [MyApp.Hooks]` is for a host + # that wants a lifecycle event without taking over `:hooks_module`, which + # would move every fan-out hook's primary result off `Default`. A host + # module exports only what it implements, and is called only for that. defp lifecycle_modules do base = module() + host_mods = Application.get_env(:gamend_core, :host_hook_modules, []) plugin_mods = Enum.map(PluginManager.hook_modules(), fn {_name, mod} -> mod end) - [base | plugin_mods] + ([base | host_mods] ++ plugin_mods) |> Enum.uniq() end @@ -1500,13 +1506,10 @@ defmodule Gamend.Hooks do # # Outside a transaction the generous budget stays: a hook doing real work on # its own time blocks nothing but its own request. - defp default_hook_timeout do - if Gamend.Repo.in_transaction?() do - Application.get_env(:gamend_core, :hooks_call_timeout_in_transaction, 5_000) - else - Application.get_env(:gamend_core, :hooks_call_timeout, 60_000) - end - end + # + # Both budgets are settings (`GAMEND_HOOKS_CALL_TIMEOUT_MS` and + # `GAMEND_HOOKS_CALL_TIMEOUT_IN_TRANSACTION_MS`). + defp default_hook_timeout, do: PluginManager.call_timeout_ms() end defmodule Gamend.Hooks.Default do @@ -1819,32 +1822,49 @@ defmodule Gamend.Hooks.Default do @impl true def on_custom_hook(_hook, _args), do: {:error, :not_implemented} + # Optimistic, so the plugins' `before_user_update` hook (up to its timeout) + # runs outside the lock: read, merge and ask the hook unlocked, then write + # under the lock only if the metadata is still what the merge started from. + # A concurrent change starts it over; the lock guards only the write. + @payment_metadata_attempts 3 + defp update_user_payment_metadata(user_id, fun) when is_binary(user_id) and is_function(fun, 1) do - case Gamend.Lock.serialize("user_payment_metadata", user_id, fn -> - apply_user_payment_metadata(user_id, fun) - end) do - {:ok, :ok} -> :ok - {:ok, {:error, reason}} -> {:error, reason} - {:error, reason} -> {:error, reason} - end + update_user_payment_metadata(user_id, fun, @payment_metadata_attempts) end defp update_user_payment_metadata(_user_id, _fun), do: :ok - defp apply_user_payment_metadata(user_id, fun) do - case Gamend.Accounts.get_user(user_id) do - %User{} = user -> update_loaded_user_payment_metadata(user, fun) - nil -> {:error, :user_not_found} + defp update_user_payment_metadata(user_id, fun, attempts) do + with %User{} = user <- Gamend.Repo.get(User, user_id) || {:error, :user_not_found}, + {:ok, attrs} <- + Gamend.Accounts.run_before_user_update(user, %{metadata: fun.(user.metadata)}) do + "user_payment_metadata" + |> Gamend.Lock.serialize(user_id, fn -> write_payment_metadata(user, attrs) end) + |> case do + {:ok, :stale} when attempts > 1 -> + update_user_payment_metadata(user_id, fun, attempts - 1) + + {:ok, :stale} -> + {:error, :conflict} + + {:ok, {:ok, _user}} -> + :ok + + {:ok, {:error, reason}} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end end end - defp update_loaded_user_payment_metadata(%User{} = user, fun) do - metadata = fun.(user.metadata) - - case Gamend.Accounts.update_user(user, %{metadata: metadata}) do - {:ok, _user} -> :ok - {:error, reason} -> {:error, reason} + defp write_payment_metadata(%User{id: id, metadata: read}, attrs) do + case Gamend.Repo.get(User, id) do + %User{metadata: ^read} = current -> Gamend.Accounts.apply_user_update(current, attrs) + %User{} -> :stale + nil -> {:error, :user_not_found} end end diff --git a/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex b/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex index 4a6cdf344..c89bd05de 100644 --- a/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex +++ b/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex @@ -32,8 +32,47 @@ defmodule Gamend.Hooks.PluginManager do @type plugin_name :: String.t() @type plugin_app :: atom() + # Plugin lifecycle calls (reload, `after_startup`). Hook calls take the + # declared `call_timeout_ms` instead. @timeout_ms 60_000 - @default_slow_hook_threshold_ms 200.0 + + use Gamend.Settings.Provider, + app: :gamend_core, + group: :hooks, + label: "Hooks" + + setting(:call_timeout_ms, :integer, + default: 60_000, + doc: + "How long a plugin hook or RPC may run before it is killed, in ms. The caller's " <> + "request waits that long." + ) + + setting(:call_timeout_in_transaction_ms, :integer, + default: 5_000, + doc: + "The same, for a hook called inside a database transaction: on SQLite that " <> + "transaction holds the only write connection while the hook runs." + ) + + setting(:slow_threshold_ms, :integer, + default: 200, + doc: "Log a hook call as slow when it takes longer than this, in ms." + ) + + @doc """ + How long a hook call may run, in ms: `call_timeout_in_transaction_ms` inside a + `Repo` transaction, `call_timeout_ms` otherwise. + """ + @spec call_timeout_ms() :: pos_integer() + def call_timeout_ms do + key = + if Gamend.Repo.in_transaction?(), + do: :call_timeout_in_transaction_ms, + else: :call_timeout_ms + + max(Gamend.Settings.get(__MODULE__, key), 1) + end defmodule Plugin do @moduledoc """ @@ -193,7 +232,7 @@ defmodule Gamend.Hooks.PluginManager do defp do_call_rpc(plugin, fn_name, args, opts) do case lookup(plugin) do {:ok, %Plugin{status: :ok, hooks_module: mod}} when is_atom(mod) and not is_nil(mod) -> - timeout = Keyword.get(opts, :timeout_ms, @timeout_ms) + timeout = Keyword.get_lazy(opts, :timeout_ms, &call_timeout_ms/0) case resolve_function_atom(mod, fn_name, length(args)) do {:ok, fun_atom} -> @@ -377,13 +416,7 @@ defmodule Gamend.Hooks.PluginManager do |> :erlang.float_to_binary(decimals: 3) end - defp slow_hook_threshold_ms do - Application.get_env( - :gamend_core, - :slow_hook_threshold_ms, - @default_slow_hook_threshold_ms - ) - end + defp slow_hook_threshold_ms, do: Gamend.Settings.get(__MODULE__, :slow_threshold_ms) defp do_reload(prev_state) when is_map(prev_state) do # Dynamic RPC exports are derived from the currently loaded plugins. diff --git a/apps/gamend_core/lib/gamend/http.ex b/apps/gamend_core/lib/gamend/http.ex new file mode 100644 index 000000000..db26578d2 --- /dev/null +++ b/apps/gamend_core/lib/gamend/http.ex @@ -0,0 +1,54 @@ +defmodule Gamend.HTTP do + @moduledoc """ + `Req` with the declared timeout and retries, for the calls core makes to other + services while a player waits: payment receipt checks (Apple, Google Play, + Steam), OAuth code exchanges, Google ID-token checks and avatar mirroring. + + Req's own defaults suit a script, not a request handler: 15 seconds a try + and, for a GET, three retries with backoff, so one slow provider held a login + or a purchase check open for about a minute. Here each try waits + `client_timeout_ms` and a failed GET is retried `client_retries` times. A POST + is not retried, as in Req. + + `get/2` and `post/2` take what `Req.get/2` and `Req.post/2` take, so this + module drops in wherever a module takes an injectable HTTP client. Options a + caller passes win over the declared ones. + """ + + use Gamend.Settings.Provider, + app: :gamend_core, + group: :http, + label: "Server & HTTP" + + setting(:client_timeout_ms, :integer, + default: 10_000, + doc: + "Per-try timeout for calls to payment, OAuth and avatar providers, in milliseconds: " <> + "connecting, and waiting for the response." + ) + + setting(:client_retries, :integer, + default: 1, + doc: "Retries of a failed GET to a provider. POSTs are never retried. 0 disables." + ) + + @doc "`Req.get/2` with the declared timeout and retries." + @spec get(String.t() | URI.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, Exception.t()} + def get(url, opts \\ []), do: Req.get(url, options(opts)) + + @doc "`Req.post/2` with the declared timeout and retries." + @spec post(String.t() | URI.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, Exception.t()} + def post(url, opts \\ []), do: Req.post(url, options(opts)) + + @doc "The declared timeout and retry options, with `opts` over them." + @spec options(keyword()) :: keyword() + def options(opts \\ []) do + timeout = max(Gamend.Settings.get(__MODULE__, :client_timeout_ms), 1) + retries = max(Gamend.Settings.get(__MODULE__, :client_retries), 0) + + Keyword.merge( + [receive_timeout: timeout, connect_options: [timeout: timeout], max_retries: retries], + opts + ) + end +end diff --git a/apps/gamend_core/lib/gamend/inventory.ex b/apps/gamend_core/lib/gamend/inventory.ex index af36d2591..9efb414d9 100644 --- a/apps/gamend_core/lib/gamend/inventory.ex +++ b/apps/gamend_core/lib/gamend/inventory.ex @@ -208,8 +208,7 @@ defmodule Gamend.Inventory do defp topic(user_id), do: @topic_prefix <> user_id defp broadcast(user_id, item, quantity, delta) do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( topic(user_id), {:inventory_updated, %{item: item, quantity: quantity, delta: delta}} ) diff --git a/apps/gamend_core/lib/gamend/jobs.ex b/apps/gamend_core/lib/gamend/jobs.ex index 8b23e471d..e6ec9e973 100644 --- a/apps/gamend_core/lib/gamend/jobs.ex +++ b/apps/gamend_core/lib/gamend/jobs.ex @@ -45,6 +45,42 @@ defmodule Gamend.Jobs do @type args :: map() + # Read by `oban_config/0` when Oban starts, so a change needs a restart. + use Gamend.Settings.Provider, + app: :gamend_core, + group: :jobs, + label: "Background jobs" + + setting(:queue_default, :integer, + default: 10, + doc: "Per-node concurrent jobs on the default queue." + ) + + setting(:queue_hooks, :integer, + default: 20, + doc: "Per-node concurrent jobs on the hooks queue: enqueued and scheduled plugin hooks." + ) + + setting(:queue_mailers, :integer, + default: 5, + doc: "Per-node concurrent email sends." + ) + + setting(:queue_storage, :integer, + default: 5, + doc: "Per-node concurrent storage jobs, such as avatar mirroring." + ) + + setting(:queue_webhooks, :integer, + default: 10, + doc: "Per-node concurrent outgoing webhook deliveries." + ) + + setting(:prune_after_days, :integer, + default: 7, + doc: "Days finished, cancelled and discarded jobs are kept before they are deleted." + ) + @doc false # Internal: enqueue any Oban.Worker module. Plugins use enqueue_hook/enqueue_in. @spec enqueue(module(), args(), keyword()) :: {:ok, Oban.Job.t()} | {:error, term()} @@ -117,9 +153,46 @@ defmodule Gamend.Jobs do :gamend_core |> Application.fetch_env!(Oban) |> Keyword.put(:engine, engine) + |> apply_settings() |> then(&if postgres?, do: &1, else: throttle_for_sqlite(&1)) end + # The declared queue sizes and pruning window win over the compiled Oban + # config. A queue a host added to that config keeps its own size. + defp apply_settings(opts) do + opts + |> Keyword.update(:queues, declared_queues(), fn + queues when is_list(queues) -> Keyword.merge(queues, declared_queues()) + other -> other + end) + |> Keyword.update(:plugins, [], fn + plugins when is_list(plugins) -> Enum.map(plugins, &put_prune_age/1) + other -> other + end) + end + + defp declared_queues do + [ + default: queue_setting(__MODULE__, :queue_default), + hooks: queue_setting(__MODULE__, :queue_hooks), + mailers: queue_setting(__MODULE__, :queue_mailers), + storage: queue_setting(__MODULE__, :queue_storage), + webhooks: queue_setting(__MODULE__, :queue_webhooks), + push: queue_setting(Gamend.Push, :queue_concurrency) + ] + end + + defp queue_setting(module, key), do: max(Gamend.Settings.get(module, key), 1) + + defp put_prune_age(Oban.Plugins.Pruner), do: put_prune_age({Oban.Plugins.Pruner, []}) + + defp put_prune_age({Oban.Plugins.Pruner, plugin_opts}) do + days = max(Gamend.Settings.get(__MODULE__, :prune_after_days), 1) + {Oban.Plugins.Pruner, Keyword.put(plugin_opts, :max_age, days * 86_400)} + end + + defp put_prune_age(plugin), do: plugin + # Queue concurrency is sized for Postgres, which runs writers in parallel. # SQLite takes a single database-wide write lock, so that same configuration # only converts parallelism into lock contention: dozens of concurrent jobs diff --git a/apps/gamend_core/lib/gamend/kv.ex b/apps/gamend_core/lib/gamend/kv.ex index 80560fd5f..2da558b9e 100644 --- a/apps/gamend_core/lib/gamend/kv.ex +++ b/apps/gamend_core/lib/gamend/kv.ex @@ -71,7 +71,6 @@ defmodule Gamend.KV do key: String.t() ] - @kv_cache_ttl_ms 60_000 @pubsub Gamend.PubSub @doc """ @@ -186,7 +185,7 @@ defmodule Gamend.KV do Gamend.Cache.put( cache_key(key, user_id, lobby_id), payload, - ttl: @kv_cache_ttl_ms + ttl: Gamend.Cache.ttl() ) :ok @@ -294,6 +293,35 @@ defmodule Gamend.KV do :ok end + @doc """ + Delete every entry scoped to a lobby, in one statement: for deleting the lobby. + + The per-entry cache invalidations and `kv_deleted` broadcasts wait for the + enclosing transaction to commit (`Gamend.AfterCommit`). One `delete/2` per + entry cost a statement and two cache round-trips each while the caller held + the lobby's lock. Returns the number of entries deleted. + """ + @spec delete_lobby_entries(Ecto.UUID.t()) :: non_neg_integer() + def delete_lobby_entries(lobby_id) when is_binary(lobby_id) do + scoped = where(Entry, [e], e.lobby_id == ^lobby_id) + entries = scoped |> select([e], {e.key, e.user_id}) |> Repo.all() + {count, _} = Repo.delete_all(scoped) + + Gamend.AfterCommit.defer(fn -> + Enum.each(entries, fn {key, user_id} -> + _ = Gamend.Cache.invalidate(cache_key(key, user_id, lobby_id)) + _ = broadcast_kv_deleted(key, user_id, lobby_id) + end) + + entries + |> Enum.map(&elem(&1, 1)) + |> Enum.uniq() + |> Enum.each(&invalidate_entries_cache(&1, lobby_id)) + end) + + count + end + @doc """ Delete every entry a user holds inside one lobby. @@ -379,7 +407,7 @@ defmodule Gamend.KV do ) Gamend.Async.run(fn -> - _ = Gamend.Cache.put(cache_key, entries, ttl: @kv_cache_ttl_ms) + _ = Gamend.Cache.put(cache_key, entries, ttl: Gamend.Cache.ttl()) :ok end) @@ -418,7 +446,7 @@ defmodule Gamend.KV do |> Repo.aggregate(:count) Gamend.Async.run(fn -> - _ = Gamend.Cache.put(cache_key, count, ttl: @kv_cache_ttl_ms) + _ = Gamend.Cache.put(cache_key, count, ttl: Gamend.Cache.ttl()) :ok end) @@ -583,8 +611,7 @@ defmodule Gamend.KV do end defp broadcast_kv_updated(key, user_id, lobby_id, value, metadata) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( topic(key, user_id, lobby_id), {:kv_updated, %{ @@ -598,8 +625,7 @@ defmodule Gamend.KV do end defp broadcast_kv_deleted(key, user_id, lobby_id) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( topic(key, user_id, lobby_id), {:kv_deleted, %{key: key, user_id: user_id, lobby_id: lobby_id}} ) @@ -621,7 +647,7 @@ defmodule Gamend.KV do Gamend.Cache.put( cache_key(key, user_id, lobby_id), %{value: entry.value, metadata: entry.metadata}, - ttl: @kv_cache_ttl_ms + ttl: Gamend.Cache.ttl() ) :ok diff --git a/apps/gamend_core/lib/gamend/leaderboards.ex b/apps/gamend_core/lib/gamend/leaderboards.ex index 6c9e3612a..21506cd8f 100644 --- a/apps/gamend_core/lib/gamend/leaderboards.ex +++ b/apps/gamend_core/lib/gamend/leaderboards.ex @@ -36,7 +36,6 @@ defmodule Gamend.Leaderboards do alias Gamend.Leaderboards.Record alias Gamend.Repo.AdvisoryLock - @leaderboards_cache_ttl_ms 60_000 @records_cache_ttl_ms 10_000 # Name of the ranking CTE used by `list_records_around_user/3`. One @@ -199,7 +198,7 @@ defmodule Gamend.Leaderboards do @decorate cacheable( key: {:leaderboards, :get, leaderboards_cache_version(), id}, - opts: [ttl: @leaderboards_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp get_leaderboard_cached(id) when is_binary(id) do Repo.get(Leaderboard, id) @@ -307,7 +306,7 @@ defmodule Gamend.Leaderboards do @decorate cacheable( key: {:leaderboards, :list_groups, leaderboards_cache_version(), page, page_size}, - opts: [ttl: @leaderboards_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp list_leaderboard_groups_cached(page, page_size) do offset = max((page - 1) * page_size, 0) @@ -379,7 +378,7 @@ defmodule Gamend.Leaderboards do @decorate cacheable( key: {:leaderboards, :count_groups, leaderboards_cache_version()}, - opts: [ttl: @leaderboards_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp count_leaderboard_groups_cached do from(lb in Leaderboard, @@ -441,7 +440,7 @@ defmodule Gamend.Leaderboards do key: {:leaderboards, :list, leaderboards_cache_version(), opts, order_by, page, page_size}, - opts: [ttl: @leaderboards_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp list_leaderboards_cached(opts, order_by, page, page_size) do opts @@ -464,7 +463,7 @@ defmodule Gamend.Leaderboards do @decorate cacheable( key: {:leaderboards, :count, leaderboards_cache_version(), opts}, - opts: [ttl: @leaderboards_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp count_leaderboards_cached(opts) do opts diff --git a/apps/gamend_core/lib/gamend/ledger.ex b/apps/gamend_core/lib/gamend/ledger.ex index 2a936c3bd..0c33f2657 100644 --- a/apps/gamend_core/lib/gamend/ledger.ex +++ b/apps/gamend_core/lib/gamend/ledger.ex @@ -46,7 +46,7 @@ defmodule Gamend.Ledger do # A balance row and its ledger row reference only the user, so a foreign # key that fires here means the user was deleted after the context checked. Repo.rescue_foreign_key(:user_not_found, fn -> - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> case apply_fun.() do {:ok, new_total} -> record_fun.(new_total) diff --git a/apps/gamend_core/lib/gamend/limits.ex b/apps/gamend_core/lib/gamend/limits.ex index 0f0f8e4d7..20f5f5b5e 100644 --- a/apps/gamend_core/lib/gamend/limits.ex +++ b/apps/gamend_core/lib/gamend/limits.ex @@ -135,6 +135,12 @@ defmodule Gamend.Limits do doc: "Live (non-disabled) device tokens per user." ) + # ── API tokens ────────────────────────────────────────── + setting(:max_api_tokens_per_user, :integer, + default: 10, + doc: "Personal API tokens one user may hold, revoked ones not counted." + ) + # Byte caps (not characters): FCM and APNs limit the wire payload to 4096 # bytes, so only byte caps can guarantee deliverability. setting(:max_push_title, :integer, default: 255) @@ -200,6 +206,16 @@ defmodule Gamend.Limits do doc: "Hard cap on a ticket's own max_players setting." ) + setting(:matchmaking_default_min_players, :integer, + default: 2, + doc: "Smallest match a ticket forms when it does not say." + ) + + setting(:matchmaking_default_max_players, :integer, + default: 5, + doc: "Largest match a ticket forms when it does not say. At most max_matchmaking_players." + ) + setting(:max_matchmaking_params_size, :integer, default: 2_048, doc: "Serialized byte size of a ticket's match_params map." diff --git a/apps/gamend_core/lib/gamend/lobbies.ex b/apps/gamend_core/lib/gamend/lobbies.ex index 5a7d04b3c..aeb021720 100644 --- a/apps/gamend_core/lib/gamend/lobbies.ex +++ b/apps/gamend_core/lib/gamend/lobbies.ex @@ -49,10 +49,10 @@ defmodule Gamend.Lobbies do alias Bcrypt alias Ecto.Multi alias Gamend.Accounts + alias Gamend.Accounts.PasswordHash alias Gamend.Accounts.User alias Gamend.Friends alias Gamend.KV - alias Gamend.KV.Entry, as: KVEntry alias Gamend.Lobbies.Lobby alias Gamend.Lobbies.SpectatorTracker alias Gamend.Lobbies.States @@ -74,9 +74,6 @@ defmodule Gamend.Lobbies do # PubSub topic names @lobbies_topic "lobbies" - @lobby_cache_ttl_ms 60_000 - @stats_cache_ttl_ms 60_000 - defp lobby_cache_version(lobby_id) when is_binary(lobby_id) do Gamend.Cache.get!({:lobbies, :lobby_version, lobby_id}) || 1 end @@ -118,7 +115,7 @@ defmodule Gamend.Lobbies do end defp broadcast_lobbies(event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, @lobbies_topic, load_host(event)) + Gamend.Broadcast.publish(@lobbies_topic, load_host(event)) end # Every subscriber to the global list serializes the lobby independently, so @@ -138,7 +135,7 @@ defmodule Gamend.Lobbies do defp load_host(event), do: event defp broadcast_lobby(lobby_id, event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "lobby:#{lobby_id}", event) + Gamend.Broadcast.publish("lobby:#{lobby_id}", event) end @doc "Broadcast a member presence event (online/offline) to a lobby's PubSub topic." @@ -355,7 +352,7 @@ defmodule Gamend.Lobbies do spectators: non_neg_integer() } def stats do - Gamend.Cache.cached({:lobbies, :stats}, [ttl: @stats_cache_ttl_ms], fn -> + Gamend.Cache.cached({:lobbies, :stats}, [ttl: Gamend.Cache.ttl()], fn -> lobby_ids = Repo.all(from(l in Lobby, select: l.id)) spectators = @@ -637,42 +634,50 @@ defmodule Gamend.Lobbies do end end - # Wrap count + join in a transaction with advisory lock to prevent - # TOCTOU race conditions on PostgreSQL. + # The lock guards the seat count against a concurrent join, and only that. + # The slow gates run before it: the plugin's hook (up to its timeout) and the + # password check (bcrypt, ~250ms). Inside the lock they held the lobby and, + # on SQLite, the only database connection, so repeated wrong passwords from + # one player stalled every request in the server. The seat check also runs + # first, unlocked, so a full lobby is refused without calling the hook. defp do_join_with_lock(user, lobby, opts, user_id) do - Lock.serialize(:lobby, lobby.id, fn -> - member_ids = - Repo.all( - from(u in User, - where: u.lobby_id == ^lobby.id, - select: u.id - ) - ) + with :ok <- check_seat(lobby, user_id), + :ok <- run_before_join(user, lobby, opts), + :ok <- check_password(lobby, opt(opts, :password)) do + Lock.serialize(:lobby, lobby.id, fn -> + with :ok <- check_seat(lobby, user_id), + {:ok, updated_user} <- + create_membership(%{lobby_id: lobby.id, user_id: user_id}) do + updated_user + else + {:error, reason} -> Repo.rollback(reason) + end + end) + end + end - if length(member_ids) >= lobby.max_users do - Repo.rollback(:full) - end + defp check_seat(lobby, user_id) do + member_ids = + Repo.all( + from(u in User, + where: u.lobby_id == ^lobby.id, + select: u.id + ) + ) + cond do + length(member_ids) >= lobby.max_users -> {:error, :full} # A block in either direction keeps the pair apart, so the blocker does # not have to be the one already seated. - if Friends.any_blocked?(user_id, member_ids) do - Repo.rollback(:blocked) - end - - case run_before_join_and_validate(user, lobby, opts, user_id) do - {:ok, result} -> result - {:error, reason} -> Repo.rollback(reason) - end - end) + Friends.any_blocked?(user_id, member_ids) -> {:error, :blocked} + true -> :ok + end end - defp run_before_join_and_validate(user, lobby, opts, user_id) do + defp run_before_join(user, lobby, opts) do case Gamend.Hooks.internal_call(:before_lobby_join, [user, lobby, opts]) do - {:ok, _} -> - validate_and_join(lobby, user_id, opt(opts, :password)) - - {:error, reason} -> - {:error, {:hook_rejected, reason}} + {:ok, _} -> :ok + {:error, reason} -> {:error, {:hook_rejected, reason}} end end @@ -681,27 +686,17 @@ defmodule Gamend.Lobbies do defp opt(opts, key, default) when is_list(opts), do: Keyword.get(opts, key, default) defp opt(opts, key, default) when is_map(opts), do: Map.get(opts, key, default) - defp validate_and_join(lobby, user_id, password) do - case {lobby.password_hash, password} do - {nil, _} -> - create_membership(%{lobby_id: lobby.id, user_id: user_id}) - - {phash, nil} when phash != nil -> - {:error, :password_required} + defp check_password(%Lobby{password_hash: nil}, _password), do: :ok + defp check_password(%Lobby{}, nil), do: {:error, :password_required} - {phash, password} -> - if Bcrypt.verify_pass(password, phash) do - create_membership(%{lobby_id: lobby.id, user_id: user_id}) - else - {:error, :invalid_password} - end - end + defp check_password(%Lobby{password_hash: hash}, password) do + if PasswordHash.verify(password, hash), do: :ok, else: {:error, :invalid_password} end @spec get_lobby!(Ecto.UUID.t()) :: Lobby.t() @decorate cacheable( key: {:lobbies, :get, lobby_cache_version(id), id}, - opts: [ttl: @lobby_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_lobby!(id), do: Repo.get_uuid!(Lobby, id) @@ -709,7 +704,7 @@ defmodule Gamend.Lobbies do @decorate cacheable( key: {:lobbies, :get, lobby_cache_version(id), id}, match: &cache_match/1, - opts: [ttl: @lobby_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_lobby(id), do: Repo.get_uuid(Lobby, id) @@ -797,7 +792,7 @@ defmodule Gamend.Lobbies do end) |> Multi.insert(:lobby, new_lobby_changeset(attrs)) |> maybe_add_host_membership(attrs) - |> Repo.transaction() + |> Gamend.AfterCommit.transaction() {:error, reason} -> {:error, {:hook_rejected, reason}} @@ -954,21 +949,42 @@ defmodule Gamend.Lobbies do obvious answer for deleting a key or combining a list, and a rule nobody can predict is worse than one they can. """ + @merge_attempts 3 + @spec merge_metadata(Lobby.t(), map()) :: {:ok, Lobby.t()} | {:error, term()} def merge_metadata(%Lobby{} = lobby, patch) when is_map(patch) do - result = - Gamend.Lock.serialize(:lobby, lobby.id, fn -> - case get_lobby(lobby.id) do + do_merge_metadata(lobby.id, Gamend.Parse.string_keys(patch), @merge_attempts) + end + + # Optimistic, so the plugins' `before_lobby_update` hook (up to its timeout) + # runs outside the lock: merge against a fresh read and ask the hook + # unlocked, then write under the lock only if the metadata is still what the + # merge started from. A concurrent merge starts it over. The read skips the + # cache, which could hand back a version a merge already replaced. + + defp do_merge_metadata(lobby_id, patch, attempts) do + with %Lobby{} = current <- Repo.get(Lobby, lobby_id) || {:error, :not_found}, + merged = Map.merge(current.metadata || %{}, patch), + {:ok, attrs} <- run_before_lobby_update(current, %{metadata: merged}) do + Gamend.Lock.serialize(:lobby, lobby_id, fn -> + case Repo.get(Lobby, lobby_id) do + %Lobby{metadata: metadata} = fresh when metadata == current.metadata -> + apply_lobby_update(fresh, attrs) + + %Lobby{} -> + :stale + nil -> {:error, :not_found} - - current -> - merged = Map.merge(current.metadata || %{}, Gamend.Parse.string_keys(patch)) - update_lobby(current, %{metadata: merged}) end end) - - with {:ok, inner} <- result, do: inner + |> case do + {:ok, :stale} when attempts > 1 -> do_merge_metadata(lobby_id, patch, attempts - 1) + {:ok, :stale} -> {:error, :conflict} + {:ok, result} -> result + {:error, _} = error -> error + end + end end @doc """ @@ -981,49 +997,51 @@ defmodule Gamend.Lobbies do @spec update_lobby(Lobby.t(), Types.lobby_update_attrs()) :: {:ok, Lobby.t()} | {:error, Ecto.Changeset.t() | term()} def update_lobby(%Lobby{} = lobby, attrs) do - case Gamend.Hooks.internal_call(:before_lobby_update, [lobby, attrs]) do - {:ok, returned} -> - # prefer hook-returned attrs if it's a plain map; if the hook - # incorrectly returns something else (eg. a struct) fall back to - # the original params we received so updates from the form are not lost. - attrs_to_use = - if is_map(returned) and not is_struct(returned) do - returned - else - attrs - end - - attrs_to_use = normalize_changeset_params(attrs_to_use) + with {:ok, attrs_to_use} <- run_before_lobby_update(lobby, attrs) do + apply_lobby_update(lobby, attrs_to_use) + end + end - result = - lobby - |> Lobby.changeset(attrs_to_use) - |> Repo.update() + # Prefer hook-returned attrs if it's a plain map; if the hook incorrectly + # returns something else (eg. a struct) fall back to the original params we + # received so updates from the form are not lost. + defp run_before_lobby_update(lobby, attrs) do + case Gamend.Hooks.internal_call(:before_lobby_update, [lobby, attrs]) do + {:ok, returned} when is_map(returned) and not is_struct(returned) -> {:ok, returned} + {:ok, _other} -> {:ok, attrs} + {:error, reason} -> {:error, {:hook_rejected, reason}} + end + end - case result do - {:ok, updated} -> - Gamend.Async.run(fn -> - Gamend.Hooks.internal_call(:after_lobby_updated, [updated]) - end) + defp apply_lobby_update(lobby, attrs) do + result = + lobby + |> Lobby.changeset(normalize_changeset_params(attrs)) + |> Repo.update() - _ = invalidate_lobby_cache(updated.id) + case result do + {:ok, updated} -> + Gamend.Async.run(fn -> + Gamend.Hooks.internal_call(:after_lobby_updated, [updated]) + end) - # Materialize members once here so the per-socket channel fan-out - # serializes the already-loaded list instead of each subscriber - # re-querying (was O(N) queries / O(N²) rows per update). - with_members = %{updated | memberships: get_lobby_members(updated.id)} + _ = invalidate_lobby_cache(updated.id) - # broadcast updates so any UI/channel subscribers get the change - broadcast_lobby(updated.id, {:lobby_updated, with_members}) - broadcast_lobbies({:lobby_updated, updated}) - {:ok, updated} + # After commit, with the members query: a lock around the update + # (`merge_metadata/2`) need not wait on either. Members are + # materialized once here so the per-socket channel fan-out serializes + # the already-loaded list instead of each subscriber re-querying (was + # O(N) queries / O(N²) rows per update). + Gamend.AfterCommit.defer(fn -> + with_members = %{updated | memberships: get_lobby_members(updated.id)} + broadcast_lobby(updated.id, {:lobby_updated, with_members}) + broadcast_lobbies({:lobby_updated, updated}) + end) - other -> - other - end + {:ok, updated} - {:error, reason} -> - {:error, {:hook_rejected, reason}} + other -> + other end end @@ -1165,13 +1183,14 @@ defmodule Gamend.Lobbies do end defp do_delete_lobby(%Lobby{id: lobby_id} = lobby) when is_binary(lobby_id) do - Lock.serialize(:lobby, lobby_id, fn -> - # Before anything is unwound: members are about to be detached and the - # lobby's KV deleted, so this is the last moment the run's final state is - # readable. Gathered synchronously for that reason; the write itself is - # buffered outside this transaction, so a rollback still keeps the record. - _ = Gamend.LobbySnapshots.capture_lobby(lobby_id, "lobby:deleted", sync: true) + # Before anything is unwound: members are about to be detached and the + # lobby's KV deleted, so this is the last moment the run's final state is + # readable. Gathered before the lock rather than inside it: reading up to + # the KV cap, encoding and hashing it held the lobby and, on SQLite, the + # only write lock. The write is buffered, so a rollback still keeps it. + _ = Gamend.LobbySnapshots.capture_lobby(lobby_id, "lobby:deleted", sync: true) + Lock.serialize(:lobby, lobby_id, fn -> # Whole structs rather than ids: delete_lobby/1 announces the detachment # to each of them afterwards, and this is the last moment they are # readable as members of this lobby. @@ -1183,7 +1202,7 @@ defmodule Gamend.Lobbies do set: [lobby_id: nil] ) - delete_lobby_kv_entries(lobby_id) + _ = KV.delete_lobby_entries(lobby_id) case Repo.delete(lobby) do {:ok, deleted} -> {deleted, members} @@ -1196,17 +1215,6 @@ defmodule Gamend.Lobbies do kind, reason -> {:error, {kind, reason}} end - defp delete_lobby_kv_entries(lobby_id) when is_binary(lobby_id) do - from(e in KVEntry, - where: e.lobby_id == ^lobby_id, - select: {e.key, e.user_id, e.lobby_id} - ) - |> Repo.all() - |> Enum.each(fn {key, user_id, entry_lobby_id} -> - KV.delete(key, user_id: user_id, lobby_id: entry_lobby_id) - end) - end - @spec change_lobby(Lobby.t()) :: Ecto.Changeset.t() @spec change_lobby(Lobby.t(), map()) :: Ecto.Changeset.t() def change_lobby(%Lobby{} = lobby, attrs \\ %{}) do @@ -1308,13 +1316,16 @@ defmodule Gamend.Lobbies do defp do_leave_lobby(membership, lobby, user_id) do lobby_id = lobby.id + emptied_capture = prepare_emptied_capture(lobby, user_id, membership.id) result = - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> Repo.update!(Ecto.Changeset.change(membership, %{lobby_id: nil})) handle_host_transfer(lobby, user_id, membership.id) end) + if match?({:ok, :lobby_deleted}, result), do: Gamend.LobbySnapshots.record(emptied_capture) + # Let plugins react to the state that is about to be wiped (e.g. bank cargo # collected in a level the player is abandoning). Runs synchronously, before # the KV clear, and only when the leave actually committed. @@ -1357,6 +1368,21 @@ defmodule Gamend.Lobbies do defp clear_lobby_scoped_kv(_user_id, _lobby_id), do: 0 + # The host's leave deletes the lobby when no one else is seated; its final + # state is read here, before the transaction, rather than inside it, where + # reading and hashing it held the only SQLite write lock. It is recorded only + # if the lobby was then deleted. No one else seated is checked again inside: + # a player joining in between keeps the lobby, and the capture is dropped. + defp prepare_emptied_capture(lobby, user_id, membership_id) do + if lobby.host_id == user_id and not lobby.hostless and + not Repo.exists?( + from u in Gamend.Accounts.User, + where: u.lobby_id == ^lobby.id and u.id != ^membership_id + ) do + Gamend.LobbySnapshots.prepare(lobby.id, "lobby:emptied") + end + end + defp handle_host_transfer(lobby, user_id, membership_id) do # if user was host, transfer host or delete lobby if empty if lobby.host_id == user_id and not lobby.hostless do @@ -1375,8 +1401,8 @@ defmodule Gamend.Lobbies do {:host_changed, new_host_id} [] -> - # no members left - delete lobby - _ = Gamend.LobbySnapshots.capture_lobby(lobby.id, "lobby:emptied", sync: true) + # no members left - delete lobby (its snapshot was gathered before + # the transaction, `prepare_emptied_capture/3`) _ = Repo.delete(lobby) _ = invalidate_lobby_cache(lobby.id) :lobby_deleted @@ -1676,14 +1702,18 @@ defmodule Gamend.Lobbies do end end + # Argon2id, as account passwords are (`PasswordHash`): bcrypt at cost 12 + # spent ~250ms of CPU on every join attempt, ten times Argon2id's here, and a + # join is something any signed-in player can repeat. Existing bcrypt hashes + # still verify. defp maybe_hash_password(attrs) when is_map(attrs) do cond do Map.has_key?(attrs, "password") and attrs["password"] != nil -> - Map.put(attrs, "password_hash", Bcrypt.hash_pwd_salt(attrs["password"])) + Map.put(attrs, "password_hash", PasswordHash.hash(attrs["password"])) |> Map.delete("password") Map.has_key?(attrs, :password) and attrs[:password] != nil -> - Map.put(attrs, :password_hash, Bcrypt.hash_pwd_salt(attrs[:password])) + Map.put(attrs, :password_hash, PasswordHash.hash(attrs[:password])) |> Map.delete(:password) true -> diff --git a/apps/gamend_core/lib/gamend/lobby_snapshots.ex b/apps/gamend_core/lib/gamend/lobby_snapshots.ex index a411a1359..8b6af3881 100644 --- a/apps/gamend_core/lib/gamend/lobby_snapshots.ex +++ b/apps/gamend_core/lib/gamend/lobby_snapshots.ex @@ -71,6 +71,25 @@ defmodule Gamend.LobbySnapshots do def capture_lobby(_lobby_id, _trigger, _opts), do: :ok + @doc """ + Gathers a capture now without recording it; `record/1` records it later. + + For a caller that must read the state before a transaction unwinds it, and + outside that transaction, but records it only if the transaction commits + (the last member leaving deletes the lobby). `nil` when snapshots are off or + there is nothing to read. Takes `capture_lobby/3`'s options but `:sync`. + """ + @spec prepare(String.t(), String.t(), keyword()) :: map() | nil + def prepare(lobby_id, trigger, opts \\ []) + when is_binary(lobby_id) and is_binary(trigger) and is_list(opts) do + if enabled?(), do: build_capture(lobby_id, trigger, opts) + end + + @doc "Records a capture `prepare/3` gathered. `nil` records nothing." + @spec record(map() | nil) :: :ok + def record(nil), do: :ok + def record(capture) when is_map(capture), do: Writer.enqueue_snapshot(capture) + @doc """ Record a decision that happened within the current snapshot interval. @@ -220,25 +239,29 @@ defmodule Gamend.LobbySnapshots do ## Gathering defp enqueue_capture(lobby_id, trigger, opts) do + lobby_id |> build_capture(trigger, opts) |> record() + end + + defp build_capture(lobby_id, trigger, opts) do case gather_sections(lobby_id) do sections when map_size(sections) > 0 -> - Writer.enqueue_snapshot(%{ + %{ lobby_id: lobby_id, trigger: trigger, sections: hash_sections(sections), flagged: Keyword.get(opts, :flagged, false), user_id: Keyword.get(opts, :user_id) - }) + } _ -> # Lobby already gone and nothing left to read — a capture that lost the # race with teardown. Nothing to record. - :ok + nil end rescue e -> Logger.warning("lobby_snapshots: gather failed lobby_id=#{lobby_id} #{inspect(e)}") - :ok + nil end # Hashed here rather than in the writer: the writer is one process serving diff --git a/apps/gamend_core/lib/gamend/lock.ex b/apps/gamend_core/lib/gamend/lock.ex index 0b2bc50c0..3e2192145 100644 --- a/apps/gamend_core/lib/gamend/lock.ex +++ b/apps/gamend_core/lib/gamend/lock.ex @@ -14,7 +14,9 @@ defmodule Gamend.Lock do Serialized on **both** adapters, per key, by different mechanisms: - * **PostgreSQL** — `pg_advisory_xact_lock` inside the transaction. + * **PostgreSQL** — `pg_advisory_xact_lock` inside the transaction, taken + after a node-local mutex so a node's own waiters queue in the BEAM rather + than each holding a pooled connection while blocked. * **SQLite** — `Gamend.Lock.Local`, a `:global` mutex taken *around* the transaction, since SQLite has no advisory locks and its single-writer rule covers neither a read-modify-write across statements nor a critical @@ -30,6 +32,15 @@ defmodule Gamend.Lock do waiting on a mutex whose holder wants that lock deadlocks — so it runs under the outer transaction and relies on the outer lock. + ## Effects + + `serialize/3` is a `Gamend.AfterCommit` scope: broadcasts, hook tasks + (`Gamend.Async.run/1`) and anything else deferred inside `fun` run once the + transaction has committed and the lock is released. Keep `fun` to database + work. Anything slow in it (hashing a password, a plugin's `before_*` hook, + an HTTP call) holds the lock and, on SQLite, the only connection: do it + before, and re-check inside only what the lock protects. + ## Prefer an atomic write Prefer an atomic write where one exists: `Economy.spend/4` does @@ -73,6 +84,7 @@ defmodule Gamend.Lock do or `{:error, reason}` if the transaction rolls back. """ + alias Gamend.AfterCommit alias Gamend.Lock.Local alias Gamend.Repo alias Gamend.Repo.AdvisoryLock @@ -104,10 +116,20 @@ defmodule Gamend.Lock do _ = AdvisoryLock.namespace_id(namespace) cond do + AdvisoryLock.postgres?() and Repo.in_transaction?() -> + advisory_transaction(namespace, resource_id, fun) + + # Queue this node's callers on a mutex before they take a connection: + # taking the advisory lock inside the transaction made every waiter hold + # a pooled connection while it blocked, so one slow holder and nine + # waiters on the same lobby emptied a pool of ten. Only callers on other + # nodes now wait on the database. Effects deferred inside run after the + # commit, with the lock released (`Gamend.AfterCommit`). AdvisoryLock.postgres?() -> - Repo.transaction(fn -> - AdvisoryLock.lock(namespace, resource_id) - fun.() + AfterCommit.collect(fn -> + Local.trans_on_node({namespace, resource_id}, fn -> + advisory_transaction(namespace, resource_id, fun) + end) end) # Nested under an outer transaction: taking the mutex here inverts lock @@ -120,7 +142,66 @@ defmodule Gamend.Lock do true -> # Lock outside, transaction inside: holds SQLite's single writer for the # shortest time rather than queueing callers inside `busy_timeout`. - Local.trans({namespace, resource_id}, fn -> Repo.transaction(fun) end) + AfterCommit.collect(fn -> + Local.trans({namespace, resource_id}, fn -> Repo.transaction(fun) end) + end) end end + + defp advisory_transaction(namespace, resource_id, fun) do + Repo.transaction(fn -> + AdvisoryLock.lock(namespace, resource_id) + fun.() + end) + end + + @doc """ + Runs `fun` holding the `(namespace, resource_id)` lock but not inside a + transaction: for a job that must run once cluster-wide and makes its own + short writes, such as `Gamend.Tournaments.tick/1`. Each write inside commits, + and runs its deferred effects, by itself. + + A transaction held across the whole job kept every row it touched locked + until the end and, on SQLite, the database's single write lock with them. + + * **SQLite** — the mutex alone gives the once-only guarantee: SQLite is one + node. + * **Postgres** — a session-level advisory lock on a checked-out + connection, which `fun` then runs on, committing as it goes. It holds one + pooled connection for the job, not a transaction. If the caller dies, + DBConnection closes that connection, which releases the lock. + + Returns `{:ok, result}`. Inside an open transaction it is `serialize/3`. + """ + @spec exclusive(atom() | String.t(), String.t(), (-> result)) :: + {:ok, result} | {:error, term()} + when result: term() + def exclusive(namespace, resource_id, fun) + when (is_atom(namespace) or is_binary(namespace)) and is_binary(resource_id) and + is_function(fun, 0) do + _ = AdvisoryLock.namespace_id(namespace) + + cond do + Repo.in_transaction?() -> serialize(namespace, resource_id, fun) + AdvisoryLock.postgres?() -> exclusive_on_postgres(namespace, resource_id, fun) + true -> {:ok, Local.trans({namespace, resource_id}, fun)} + end + end + + defp exclusive_on_postgres(namespace, resource_id, fun) do + Local.trans_on_node({namespace, resource_id}, fn -> + Repo.checkout( + fn -> + :ok = AdvisoryLock.lock_session(namespace, resource_id) + + try do + {:ok, fun.()} + after + AdvisoryLock.unlock_session(namespace, resource_id) + end + end, + timeout: :infinity + ) + end) + end end diff --git a/apps/gamend_core/lib/gamend/lock/local.ex b/apps/gamend_core/lib/gamend/lock/local.ex index 1e12e549e..fe899c3ed 100644 --- a/apps/gamend_core/lib/gamend/lock/local.ex +++ b/apps/gamend_core/lib/gamend/lock/local.ex @@ -18,4 +18,14 @@ defmodule Gamend.Lock.Local do def trans(key, fun) when is_function(fun, 0) do :global.trans({{__MODULE__, key}, self()}, fun) end + + @doc """ + As `trans/2`, on this node only. On Postgres the advisory lock is the + cluster-wide one; this queues a node's own callers in the BEAM, where waiting + is free, instead of each holding a pooled connection blocked on the database. + """ + @spec trans_on_node(term(), (-> result)) :: result when result: term() + def trans_on_node(key, fun) when is_function(fun, 0) do + :global.trans({{__MODULE__, key}, self()}, fun, [node()]) + end end diff --git a/apps/gamend_core/lib/gamend/mail.ex b/apps/gamend_core/lib/gamend/mail.ex index f3aac91a5..3bf3c04d0 100644 --- a/apps/gamend_core/lib/gamend/mail.ex +++ b/apps/gamend_core/lib/gamend/mail.ex @@ -46,6 +46,13 @@ defmodule Gamend.Mail do # Providers reject or spam-file mail from an unverified sender domain, so # these matter more than they look. + setting(:send_timeout_ms, :integer, + default: 30_000, + doc: + "Longest one email send may take before it is abandoned. gen_smtp itself waits " <> + "up to 20 minutes for each reply from the relay." + ) + setting(:smtp_from_name, :string, default: "Gamend") setting(:smtp_from_email, :string) end diff --git a/apps/gamend_core/lib/gamend/matchmaking.ex b/apps/gamend_core/lib/gamend/matchmaking.ex index 279be1e34..83e211684 100644 --- a/apps/gamend_core/lib/gamend/matchmaking.ex +++ b/apps/gamend_core/lib/gamend/matchmaking.ex @@ -32,9 +32,6 @@ defmodule Gamend.Matchmaking do alias Gamend.Matchmaking.Worker alias Gamend.Repo - @default_min_players 2 - @default_max_players 5 - @doc """ Adds a user — or their whole party — to the matchmaking queue. @@ -64,8 +61,8 @@ defmodule Gamend.Matchmaking do def join(%User{} = user, match_params, min_players \\ nil, max_players \\ nil) do proposed = %{ "match_params" => normalize_params(match_params), - "min_players" => min_players || @default_min_players, - "max_players" => max_players || @default_max_players + "min_players" => min_players || default_min_players(), + "max_players" => max_players || default_max_players() } # The client proposes; the game decides. A hook may rewrite the params @@ -87,6 +84,11 @@ defmodule Gamend.Matchmaking do end end + # A ticket that leaves its size out gets the server's + # (`GAMEND_LIMITS_MATCHMAKING_DEFAULT_*_PLAYERS`). + defp default_min_players, do: Limits.get(:matchmaking_default_min_players) + defp default_max_players, do: Limits.get(:matchmaking_default_max_players) + defp run_join_hook(user, proposed) do case Gamend.Hooks.internal_call(:before_matchmaking_join, [user, proposed]) do {:ok, attrs} when is_map(attrs) -> {:ok, attrs} @@ -103,7 +105,7 @@ defmodule Gamend.Matchmaking do defp resolve_queue_group(%User{} = user, proposed) do if Gamend.Parties.can_manage_party?(user, user.party_id) do members = Gamend.Parties.get_party_members(user.party_id) - max = Map.get(proposed, "max_players") || @default_max_players + max = Map.get(proposed, "max_players") || default_max_players() cond do length(members) > max -> @@ -151,15 +153,15 @@ defmodule Gamend.Matchmaking do base = %{ status: Constants.status_queued(), match_params: normalize_params(Map.get(attrs, "match_params", %{})), - min_players: Map.get(attrs, "min_players") || @default_min_players, - max_players: Map.get(attrs, "max_players") || @default_max_players, + min_players: Map.get(attrs, "min_players") || default_min_players(), + max_players: Map.get(attrs, "max_players") || default_max_players(), timeout_ms: Limits.get(:matchmaking_timeout_ms), queued_at: now, party_id: caller.party_id } result = - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> Enum.reduce_while(members, [], fn member, acc -> %Ticket{} |> Ticket.changeset(Map.put(base, :user_id, member.id)) diff --git a/apps/gamend_core/lib/gamend/matchmaking/broadcast.ex b/apps/gamend_core/lib/gamend/matchmaking/broadcast.ex index 6b8c3593e..421f6b8ec 100644 --- a/apps/gamend_core/lib/gamend/matchmaking/broadcast.ex +++ b/apps/gamend_core/lib/gamend/matchmaking/broadcast.ex @@ -9,8 +9,6 @@ defmodule Gamend.Matchmaking.Broadcast do alias Gamend.Matchmaking.Constants - @pubsub Gamend.PubSub - @doc "Notifies every matched user that a lobby has been found." @spec match_found([map()], Ecto.UUID.t()) :: :ok def match_found(tickets, lobby_id) do @@ -22,8 +20,7 @@ defmodule Gamend.Matchmaking.Broadcast do } Enum.each(tickets, fn ticket -> - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "matchmaking:user:#{ticket.user_id}", {:matchmaking_event, Constants.event_found(), payload} ) diff --git a/apps/gamend_core/lib/gamend/notifications.ex b/apps/gamend_core/lib/gamend/notifications.ex index e2a315399..d5dff7bf3 100644 --- a/apps/gamend_core/lib/gamend/notifications.ex +++ b/apps/gamend_core/lib/gamend/notifications.ex @@ -45,8 +45,6 @@ defmodule Gamend.Notifications do @type user_id :: Ecto.UUID.t() - @notifications_cache_ttl_ms 60_000 - # --------------------------------------------------------------------------- # PubSub # --------------------------------------------------------------------------- @@ -64,7 +62,7 @@ defmodule Gamend.Notifications do end defp broadcast_user(user_id, event) when is_binary(user_id) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "notifications:user:#{user_id}", event) + Gamend.Broadcast.publish("notifications:user:#{user_id}", event) end # --------------------------------------------------------------------------- @@ -109,7 +107,7 @@ defmodule Gamend.Notifications do key: {:notifications, :list, notifications_version(user_id), user_id, Keyword.get(opts, :page, 1), Keyword.get(opts, :page_size, 25)}, - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def list_notifications(user_id, opts \\ []) when is_binary(user_id) do page = Keyword.get(opts, :page, 1) @@ -148,7 +146,7 @@ defmodule Gamend.Notifications do @spec count_notifications(user_id()) :: non_neg_integer() @decorate cacheable( key: {:notifications, :count, notifications_version(user_id), user_id}, - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def count_notifications(user_id) when is_binary(user_id) do Repo.one( @@ -163,7 +161,7 @@ defmodule Gamend.Notifications do @spec count_unread_notifications(user_id()) :: non_neg_integer() @decorate cacheable( key: {:notifications, :count_unread, notifications_version(user_id), user_id}, - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def count_unread_notifications(user_id) when is_binary(user_id) do Repo.one( @@ -183,7 +181,7 @@ defmodule Gamend.Notifications do @spec list_notifications_by_title(user_id(), String.t()) :: [Notification.t()] @decorate cacheable( key: {:notifications, :by_title, notifications_version(user_id), user_id, title}, - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def list_notifications_by_title(user_id, title) when is_binary(user_id) and is_binary(title) do @@ -204,7 +202,7 @@ defmodule Gamend.Notifications do @decorate cacheable( key: {:notifications, :sent_by_title, notifications_version(user_id), user_id, title}, - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def list_sent_notifications_by_title(user_id, title) when is_binary(user_id) and is_binary(title) do @@ -261,7 +259,7 @@ defmodule Gamend.Notifications do @decorate cacheable( key: {:notifications, :get, notification_row_version(), id}, match: &(&1 != nil), - opts: [ttl: @notifications_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_notification(id) do Repo.get_uuid(Notification, id) diff --git a/apps/gamend_core/lib/gamend/oauth/exchanger.ex b/apps/gamend_core/lib/gamend/oauth/exchanger.ex index 79c558081..1c60ac65d 100644 --- a/apps/gamend_core/lib/gamend/oauth/exchanger.ex +++ b/apps/gamend_core/lib/gamend/oauth/exchanger.ex @@ -438,9 +438,10 @@ defmodule Gamend.OAuth.Exchanger do if nonce == expected_nonce, do: :ok, else: {:error, :invalid_nonce} end - # Helper to allow injecting a test HTTP client in tests. Defaults to Req. + # Helper to allow injecting a test HTTP client in tests. Defaults to + # Gamend.HTTP, Req with the declared timeout and retries. defp http_client do - Application.get_env(:gamend_core, :oauth_exchanger_client, Req) + Application.get_env(:gamend_core, :oauth_exchanger_client, Gamend.HTTP) end @spec exchange_steam_code(String.t()) :: {:ok, map()} | {:error, term()} diff --git a/apps/gamend_core/lib/gamend/oauth/google_id_token.ex b/apps/gamend_core/lib/gamend/oauth/google_id_token.ex index a98d72d39..d7292dccd 100644 --- a/apps/gamend_core/lib/gamend/oauth/google_id_token.ex +++ b/apps/gamend_core/lib/gamend/oauth/google_id_token.ex @@ -104,6 +104,6 @@ defmodule Gamend.OAuth.GoogleIDToken do end defp http_client do - Application.get_env(:gamend_core, :google_tokeninfo_client, Req) + Application.get_env(:gamend_core, :google_tokeninfo_client, Gamend.HTTP) end end diff --git a/apps/gamend_core/lib/gamend/parties.ex b/apps/gamend_core/lib/gamend/parties.ex index 57e1dd383..5bf41dcae 100644 --- a/apps/gamend_core/lib/gamend/parties.ex +++ b/apps/gamend_core/lib/gamend/parties.ex @@ -45,6 +45,7 @@ defmodule Gamend.Parties do require Logger alias Gamend.Accounts + alias Gamend.Accounts.PasswordHash alias Gamend.Accounts.PresenceStatus alias Gamend.Accounts.User alias Gamend.Friends @@ -79,7 +80,7 @@ defmodule Gamend.Parties do end defp broadcast_party(party_id, event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "party:#{party_id}", event) + Gamend.Broadcast.publish("party:#{party_id}", event) end @doc "Broadcast a member presence event (online/offline) to a party's PubSub topic." @@ -92,8 +93,6 @@ defmodule Gamend.Parties do # Cache helpers # --------------------------------------------------------------------------- - @party_invite_cache_ttl_ms 60_000 - defp party_invite_cache_version(user_id) when is_binary(user_id) do Gamend.Cache.get!({:party_invites, :version, user_id}) || 1 end @@ -105,8 +104,6 @@ defmodule Gamend.Parties do # Party-row cache: get_party is keyed by a version bumped on every party-row # write. Membership/invite changes don't touch the party row, so they don't bump. - @party_cache_ttl_ms 60_000 - @stats_cache_ttl_ms 60_000 defp party_cache_version, do: Gamend.Cache.get!({:parties, :version}) || 1 @doc """ @@ -117,7 +114,7 @@ defmodule Gamend.Parties do """ @spec stats() :: %{parties_active: non_neg_integer(), players_in_parties: non_neg_integer()} def stats do - Gamend.Cache.cached({:parties, :stats}, [ttl: @stats_cache_ttl_ms], fn -> + Gamend.Cache.cached({:parties, :stats}, [ttl: Gamend.Cache.ttl()], fn -> %{ parties_active: Repo.aggregate(Party, :count, :id), players_in_parties: Accounts.count_users_in_parties() @@ -227,7 +224,7 @@ defmodule Gamend.Parties do @decorate cacheable( key: {:parties, :get, party_cache_version(), id}, match: &(&1 != nil), - opts: [ttl: @party_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_party(id), do: Repo.get_uuid(Party, id) @@ -481,8 +478,7 @@ defmodule Gamend.Parties do "Party invite from #{leader_name}" ) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{target_user_id}", {:party_invite_cancelled, %{party_id: party.id, user_id: leader.id}} ) @@ -601,8 +597,7 @@ defmodule Gamend.Parties do ) # Real-time PubSub so the sender's UI updates immediately - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{invite.sender_id}", {:party_invite_declined, %{party_id: party_id, user_id: user.id, reason: reason_str}} ) @@ -676,8 +671,7 @@ defmodule Gamend.Parties do ) # Notify the sender that the invite was accepted via PubSub - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{invite.sender_id}", {:party_invite_accepted, %{party_id: party_id, user_id: user.id}} ) @@ -746,8 +740,7 @@ defmodule Gamend.Parties do } ) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "user:#{sender_id}", {:party_invite_declined, %{party_id: party_id, user_id: user.id}} ) @@ -777,7 +770,7 @@ defmodule Gamend.Parties do key: {:party_invites, :list, party_invite_cache_version(user_id), user_id, page, page_size}, - opts: [ttl: @party_invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp do_list_party_invitations(user_id, page, page_size) do from(i in PartyInvite, @@ -819,7 +812,7 @@ defmodule Gamend.Parties do key: {:party_invites, :list_sent, party_invite_cache_version(leader_id), leader_id, page, page_size}, - opts: [ttl: @party_invite_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) defp do_list_sent_party_invitations(leader_id, page, page_size) do from(i in PartyInvite, @@ -1433,8 +1426,7 @@ defmodule Gamend.Parties do updated = Accounts.get_user(member.id) _ = Accounts.broadcast_user_update(updated) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "lobby:#{lobby.id}", {:user_joined, lobby.id, member.id} ) @@ -1518,7 +1510,7 @@ defmodule Gamend.Parties do {:error, :password_required} {hash, pwd} -> - if Bcrypt.verify_pass(pwd, hash), do: :ok, else: {:error, :invalid_password} + if PasswordHash.verify(pwd, hash), do: :ok, else: {:error, :invalid_password} end end @@ -1569,8 +1561,7 @@ defmodule Gamend.Parties do updated = Accounts.get_user(member.id) _ = Accounts.broadcast_user_update(updated) - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( "lobby:#{lobby.id}", {:user_joined, lobby.id, member.id} ) @@ -1607,7 +1598,7 @@ defmodule Gamend.Parties do members = get_party_members(party.id) member_ids = Enum.map(members, & &1.id) - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> # Bulk-clear party_id for all members in a single query from(u in User, where: u.party_id == ^party.id) |> Repo.update_all(set: [party_id: nil]) @@ -1775,7 +1766,7 @@ defmodule Gamend.Parties do end defp broadcast_parties(event) do - Phoenix.PubSub.broadcast(Gamend.PubSub, "parties", event) + Gamend.Broadcast.publish("parties", event) end @doc "List all parties with optional filters and pagination." diff --git a/apps/gamend_core/lib/gamend/payments.ex b/apps/gamend_core/lib/gamend/payments.ex index af811604c..3635795ea 100644 --- a/apps/gamend_core/lib/gamend/payments.ex +++ b/apps/gamend_core/lib/gamend/payments.ex @@ -26,14 +26,12 @@ defmodule Gamend.Payments do alias Gamend.Repo alias Gamend.Repo.AdvisoryLock - @pubsub Gamend.PubSub @store_validation_providers ~w(apple google steam) # Cached catalog/ledger reads keyed by per-entity version counters bumped on # every write to that table via tap_bump/2. Products/provider-products change # rarely (kept warm through frequent purchases); purchases have their own # version so a buy doesn't evict the catalog. - @payments_cache_ttl_ms 60_000 defp product_version, do: Gamend.Cache.get!({:payments, :product_version}) || 1 defp provider_product_version, @@ -73,7 +71,7 @@ defmodule Gamend.Payments do @decorate cacheable( key: {:payments, :product, product_version(), id}, match: &(&1 != nil), - opts: [ttl: @payments_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_product(id), do: Repo.get_uuid(Product, id) @@ -113,7 +111,7 @@ defmodule Gamend.Payments do @decorate cacheable( key: {:payments, :provider_product, provider_product_version(), id}, match: &(&1 != nil), - opts: [ttl: @payments_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_provider_product(id) do ProviderProduct @@ -229,7 +227,7 @@ defmodule Gamend.Payments do @decorate cacheable( key: {:payments, :purchase, purchase_version(), id}, match: &(&1 != nil), - opts: [ttl: @payments_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_purchase(id), do: Repo.get_uuid(Purchase, id) |> preload_purchase() @@ -411,6 +409,12 @@ defmodule Gamend.Payments do @doc delegate_to: {StripeEvents, :cancel_stripe_subscription_at_period_end, 2} defdelegate cancel_stripe_subscription_at_period_end(user, entitlement_id), to: StripeEvents + @doc delegate_to: {StripeEvents, :stripe_customer_id, 1} + defdelegate stripe_customer_id(user), to: StripeEvents + + @doc delegate_to: {StripeEvents, :create_stripe_billing_portal, 2} + defdelegate create_stripe_billing_portal(user, return_url), to: StripeEvents + # --------------------------------------------------------------------------- # Steam # --------------------------------------------------------------------------- @@ -1208,7 +1212,7 @@ defmodule Gamend.Payments do end defp after_purchase_fulfilled(%Purchase{} = purchase) do - Phoenix.PubSub.broadcast(@pubsub, "user:#{purchase.user_id}", {:purchase_updated, purchase}) + Gamend.Broadcast.publish("user:#{purchase.user_id}", {:purchase_updated, purchase}) Gamend.Async.run(fn -> Gamend.Hooks.internal_call(:after_purchase_fulfilled, [purchase]) @@ -1216,7 +1220,7 @@ defmodule Gamend.Payments do end defp after_purchase_revoked(%Purchase{} = purchase) do - Phoenix.PubSub.broadcast(@pubsub, "user:#{purchase.user_id}", {:purchase_updated, purchase}) + Gamend.Broadcast.publish("user:#{purchase.user_id}", {:purchase_updated, purchase}) Gamend.Async.run(fn -> Gamend.Hooks.internal_call(:after_purchase_revoked, [purchase]) @@ -1225,8 +1229,7 @@ defmodule Gamend.Payments do @doc false def after_entitlement_changed(%Entitlement{} = entitlement) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "user:#{entitlement.user_id}", {:entitlement_changed, entitlement} ) diff --git a/apps/gamend_core/lib/gamend/payments/provider_config.ex b/apps/gamend_core/lib/gamend/payments/provider_config.ex index 986e715d2..1f7d3c50c 100644 --- a/apps/gamend_core/lib/gamend/payments/provider_config.ex +++ b/apps/gamend_core/lib/gamend/payments/provider_config.ex @@ -43,6 +43,30 @@ defmodule Gamend.Payments.ProviderConfig do @spec environments() :: [String.t()] def environments, do: @environments + # Managed Payments needs this API version or later on the Checkout Session + # call (docs.stripe.com/payments/managed-payments/update-checkout). + @managed_payments_min_api_version "2025-03-31.basil" + + @doc "Whether checkouts go through Stripe Managed Payments (Stripe as merchant of record)." + @spec stripe_managed_payments?() :: boolean() + def stripe_managed_payments?, + do: Gamend.Settings.get(Gamend.Payments.Settings, :stripe_managed_payments) == true + + @doc """ + The API version for creating a Checkout Session: the configured one, raised + to #{@managed_payments_min_api_version} when Managed Payments is on and the + configured one is older. Only that call is raised, so every other request, + and the payloads core parses from them, keep the configured version. + """ + @spec stripe_checkout_api_version() :: String.t() + def stripe_checkout_api_version do + configured = stripe_api_version() + + if stripe_managed_payments?() and configured < @managed_payments_min_api_version, + do: @managed_payments_min_api_version, + else: configured + end + @spec stripe_secret_key() :: String.t() | nil def stripe_secret_key, do: stripe_value(:secret_key) diff --git a/apps/gamend_core/lib/gamend/payments/providers/apple.ex b/apps/gamend_core/lib/gamend/payments/providers/apple.ex index 2a543a548..b7fe18408 100644 --- a/apps/gamend_core/lib/gamend/payments/providers/apple.ex +++ b/apps/gamend_core/lib/gamend/payments/providers/apple.ex @@ -246,7 +246,7 @@ defmodule Gamend.Payments.Providers.Apple do defp key_id, do: config_value("APPLE_KEY_ID", :apple_key_id) defp http_client do - Application.get_env(:gamend_core, :payments_http_client, Req) + Application.get_env(:gamend_core, :payments_http_client, Gamend.HTTP) end defp jws_verifier do diff --git a/apps/gamend_core/lib/gamend/payments/providers/google.ex b/apps/gamend_core/lib/gamend/payments/providers/google.ex index e863d7d01..da02303f9 100644 --- a/apps/gamend_core/lib/gamend/payments/providers/google.ex +++ b/apps/gamend_core/lib/gamend/payments/providers/google.ex @@ -405,7 +405,7 @@ defmodule Gamend.Payments.Providers.Google do end defp http_client do - Application.get_env(:gamend_core, :payments_http_client, Req) + Application.get_env(:gamend_core, :payments_http_client, Gamend.HTTP) end # The app_key is the declared setting name, so this resolves through diff --git a/apps/gamend_core/lib/gamend/payments/providers/steam.ex b/apps/gamend_core/lib/gamend/payments/providers/steam.ex index 93f17ca95..dec54ca2e 100644 --- a/apps/gamend_core/lib/gamend/payments/providers/steam.ex +++ b/apps/gamend_core/lib/gamend/payments/providers/steam.ex @@ -281,7 +281,7 @@ defmodule Gamend.Payments.Providers.Steam do end defp http_client do - Application.get_env(:gamend_core, :payments_http_client, Req) + Application.get_env(:gamend_core, :payments_http_client, Gamend.HTTP) end # The app_key is the declared setting name, so this resolves through diff --git a/apps/gamend_core/lib/gamend/payments/providers/stripe.ex b/apps/gamend_core/lib/gamend/payments/providers/stripe.ex index adcf923b5..e51bd217f 100644 --- a/apps/gamend_core/lib/gamend/payments/providers/stripe.ex +++ b/apps/gamend_core/lib/gamend/payments/providers/stripe.ex @@ -15,11 +15,16 @@ defmodule Gamend.Payments.Providers.Stripe do mode = stripe_mode(provider_product.product.kind) params = - checkout_params(provider_product, purchase, success_url, cancel_url, mode, metadata) + provider_product + |> checkout_params(purchase, success_url, cancel_url, mode, metadata) + |> put_checkout_customer(mode, attrs["stripe_customer_id"]) + |> put_managed_payments(ProviderConfig.stripe_managed_payments?()) case create_checkout_session_with_sdk( params, - stripe_request_opts(secret_key, purchase) + secret_key + |> stripe_request_opts(purchase) + |> Keyword.put(:api_version, ProviderConfig.stripe_checkout_api_version()) ) do {:ok, session} -> {:ok, normalize_stripe_payload(session)} @@ -74,6 +79,27 @@ defmodule Gamend.Payments.Providers.Stripe do end end + @doc """ + A Stripe customer-portal session for `customer_id`: the Stripe-hosted page + where the buyer cancels, changes card and downloads invoices. Returns the + session; its `"url"` is single-use and short-lived, so open it right away. + """ + def create_billing_portal_session(customer_id, return_url) + when is_binary(customer_id) and is_binary(return_url) do + with {:ok, secret_key} <- secret_key() do + case create_billing_portal_session_with_sdk( + %{customer: customer_id, return_url: return_url}, + stripe_request_opts(secret_key) + ) do + {:ok, session} -> + {:ok, normalize_stripe_payload(session)} + + {:error, reason} -> + {:error, {:stripe_error, normalize_stripe_payload(reason)}} + end + end + end + def verify_webhook(_raw_body, nil), do: {:error, :missing_stripe_signature} def verify_webhook(raw_body, signature_header) @@ -124,6 +150,30 @@ defmodule Gamend.Payments.Providers.Stripe do |> put_checkout_payment_metadata(mode, metadata) end + # One Stripe customer per account, so the portal shows every purchase. A + # returning buyer's checkout reuses their customer; a first one-off payment + # asks Stripe to create one (subscription mode always does), without which a + # lifetime buyer would have no portal and no receipts in it. The id is + # server-supplied (`StripeEvents.create_stripe_checkout/2`), never a + # client's, and only a `cus_` id is ever passed through. + defp put_checkout_customer(params, _mode, "cus_" <> _rest = customer_id), + do: Map.put(params, :customer, customer_id) + + defp put_checkout_customer(params, "payment", _customer_id), + do: Map.put(params, :customer_creation, "always") + + defp put_checkout_customer(params, _mode, _customer_id), do: params + + # Stripe as merchant of record. None of the parameters Managed Payments + # rejects (automatic_tax, payment_method_types, invoice_creation, shipping, + # statement descriptors, Connect fields) is ever sent here, so the flag is + # the whole change; the products need a Managed-Payments-eligible tax code + # in the Dashboard. + defp put_managed_payments(params, true), + do: Map.put(params, :managed_payments, %{enabled: true}) + + defp put_managed_payments(params, _enabled), do: params + defp put_checkout_payment_metadata(params, "subscription", metadata) do Map.put(params, :subscription_data, %{metadata: metadata}) end @@ -196,6 +246,12 @@ defmodule Gamend.Payments.Providers.Stripe do exception -> {:error, exception} end + defp create_billing_portal_session_with_sdk(params, opts) do + stripe_client().create_billing_portal_session(params, opts) + rescue + exception -> {:error, exception} + end + defp construct_webhook_event_with_sdk(raw_body, signature_header, secret, tolerance_seconds) do stripe_client().construct_webhook_event(raw_body, signature_header, secret, tolerance_seconds) rescue @@ -226,6 +282,7 @@ defmodule Gamend.Payments.Providers.Stripe do defmodule Client do @moduledoc false + alias Stripe.BillingPortal.Session, as: PortalSession alias Stripe.Checkout.Session alias Stripe.Subscription alias Stripe.Webhook @@ -246,6 +303,10 @@ defmodule Gamend.Payments.Providers.Stripe do Subscription.update(subscription_id, params, opts) end + def create_billing_portal_session(params, opts) do + PortalSession.create(params, opts) + end + def construct_webhook_event(raw_body, signature_header, secret, tolerance_seconds) do Webhook.construct_event(raw_body, signature_header, secret, tolerance_seconds) end diff --git a/apps/gamend_core/lib/gamend/payments/settings.ex b/apps/gamend_core/lib/gamend/payments/settings.ex index 6f32a2221..83ac72e32 100644 --- a/apps/gamend_core/lib/gamend/payments/settings.ex +++ b/apps/gamend_core/lib/gamend/payments/settings.ex @@ -41,6 +41,12 @@ defmodule Gamend.Payments.Settings do setting(:stripe_production_webhook_secret, :string, secret: true) + setting(:stripe_managed_payments, :boolean, + default: false, + doc: + "Sell through Stripe Managed Payments (Stripe is merchant of record: it charges and remits the buyer's VAT). Accept the terms and set a tax code on every product in the Stripe Dashboard first." + ) + # ── Google Play ───────────────────────────────────────── @play [:google_play_package_name, :google_play_service_account_json] diff --git a/apps/gamend_core/lib/gamend/payments/stripe_events.ex b/apps/gamend_core/lib/gamend/payments/stripe_events.ex index b766cd283..50a697665 100644 --- a/apps/gamend_core/lib/gamend/payments/stripe_events.ex +++ b/apps/gamend_core/lib/gamend/payments/stripe_events.ex @@ -27,7 +27,12 @@ defmodule Gamend.Payments.StripeEvents do }} | {:error, term()} def create_stripe_checkout(%User{} = user, attrs) when is_map(attrs) do - attrs = attrs |> Params.normalize() |> Payments.client_checkout_attrs() + attrs = + attrs + |> Params.normalize() + |> Payments.client_checkout_attrs() + # Server-side and last, so a client can never name someone else's customer. + |> Map.put("stripe_customer_id", stripe_customer_id(user)) with {:ok, provider_product} <- Payments.resolve_provider_product("stripe", attrs), :ok <- Payments.ensure_checkout_allowed(user, provider_product, attrs), @@ -53,6 +58,54 @@ defmodule Gamend.Payments.StripeEvents do end end + @doc """ + The Stripe customer this account has paid as, or nil: the newest Stripe + purchase whose stored checkout session names one. Stripe creates the customer + at checkout (subscriptions always; one-off payments since + `customer_creation: "always"`), and `checkout.session.completed` stores the + session on the purchase. + """ + @spec stripe_customer_id(User.t()) :: String.t() | nil + def stripe_customer_id(%User{id: user_id}) do + from(p in Purchase, + where: p.user_id == ^user_id and p.provider == "stripe", + order_by: [desc: p.inserted_at], + select: p.raw_provider_payload + ) + |> Repo.all() + |> Enum.find_value(&payload_customer_id/1) + end + + defp payload_customer_id(%{} = payload) do + [payload["stripe_session"], payload["stripe_subscription"]] + |> Enum.find_value(fn + %{"customer" => "cus_" <> _ = id} -> id + %{"customer" => %{"id" => "cus_" <> _ = id}} -> id + _ -> nil + end) + end + + defp payload_customer_id(_payload), do: nil + + @doc """ + Open Stripe's customer portal for this account: cancel, change card, download + invoices. `{:error, :no_stripe_customer}` when the account never paid through + Stripe Checkout. + """ + @spec create_stripe_billing_portal(User.t(), String.t()) :: + {:ok, String.t()} | {:error, term()} + def create_stripe_billing_portal(%User{} = user, return_url) when is_binary(return_url) do + with customer_id when is_binary(customer_id) <- stripe_customer_id(user), + {:ok, %{"url" => url}} when is_binary(url) <- + Payments.stripe_adapter().create_billing_portal_session(customer_id, return_url) do + {:ok, url} + else + nil -> {:error, :no_stripe_customer} + {:ok, _session} -> {:error, :stripe_portal_without_url} + {:error, reason} -> {:error, reason} + end + end + @spec handle_stripe_webhook(binary(), binary() | nil) :: {:ok, atom()} | {:error, term()} def handle_stripe_webhook(raw_body, signature) when is_binary(raw_body) do with {:ok, event} <- Payments.stripe_adapter().verify_webhook(raw_body, signature), diff --git a/apps/gamend_core/lib/gamend/push.ex b/apps/gamend_core/lib/gamend/push.ex index 0b91b4497..20b3d7ea0 100644 --- a/apps/gamend_core/lib/gamend/push.ex +++ b/apps/gamend_core/lib/gamend/push.ex @@ -37,8 +37,6 @@ defmodule Gamend.Push do @type user_id :: Ecto.UUID.t() - @push_cache_ttl_ms 60_000 - # --------------------------------------------------------------------------- # Cache helpers # --------------------------------------------------------------------------- @@ -333,7 +331,7 @@ defmodule Gamend.Push do @spec user_has_live_tokens?(user_id()) :: boolean() @decorate cacheable( key: {:push, :has_tokens, push_version(user_id), user_id}, - opts: [ttl: @push_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def user_has_live_tokens?(user_id) when is_binary(user_id) do Repo.exists?(from(t in PushToken, where: t.user_id == ^user_id and is_nil(t.disabled_at))) diff --git a/apps/gamend_core/lib/gamend/quests.ex b/apps/gamend_core/lib/gamend/quests.ex index dee7cc207..4083023be 100644 --- a/apps/gamend_core/lib/gamend/quests.ex +++ b/apps/gamend_core/lib/gamend/quests.ex @@ -66,8 +66,6 @@ defmodule Gamend.Quests do @type user_id :: Ecto.UUID.t() - @cache_ttl_ms 60_000 - # Grace before the recovery sweep retries a claimed-but-ungranted row, so it # can't race the post-commit grants of an in-flight claim. @reward_retry_grace_s 60 @@ -122,19 +120,19 @@ defmodule Gamend.Quests do defp broadcast_definition_change do invalidate_quests_cache() - Phoenix.PubSub.broadcast(@pubsub, "quests", {:quests_changed}) + Gamend.Broadcast.publish("quests", {:quests_changed}) end # Progress ticks go to the user's topic only — a global fan-out of every # objective increment would scale with total event volume across all # players. Completions/claims are rare enough to broadcast globally. defp broadcast_progress(:quest_progress = event, user_id, payload) do - Phoenix.PubSub.broadcast(@pubsub, "user:#{user_id}", {event, payload}) + Gamend.Broadcast.publish("user:#{user_id}", {event, payload}) end defp broadcast_progress(event, user_id, payload) do - Phoenix.PubSub.broadcast(@pubsub, "user:#{user_id}", {event, payload}) - Phoenix.PubSub.broadcast(@pubsub, "quests", {event, user_id, payload}) + Gamend.Broadcast.publish("user:#{user_id}", {event, payload}) + Gamend.Broadcast.publish("quests", {event, user_id, payload}) end # --------------------------------------------------------------------------- @@ -204,7 +202,7 @@ defmodule Gamend.Quests do @decorate cacheable( key: {:quests, :get, quests_version(), id}, match: &(&1 != nil), - opts: [ttl: @cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_quest(id), do: Repo.get_uuid(Quest, id) @@ -255,7 +253,7 @@ defmodule Gamend.Quests do claimed: non_neg_integer() } def stats do - Gamend.Cache.cached({:quests, :stats}, [ttl: @cache_ttl_ms], fn -> + Gamend.Cache.cached({:quests, :stats}, [ttl: Gamend.Cache.ttl()], fn -> by_status = from(p in QuestProgress, group_by: p.status, select: {p.status, count(p.id)}) |> Repo.all() @@ -300,7 +298,7 @@ defmodule Gamend.Quests do @spec active_quests() :: [Quest.t()] @decorate cacheable( key: {:quests, :active_all, quests_version()}, - opts: [ttl: @cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def active_quests do from(q in Quest, where: q.active == true, order_by: [asc: q.sort_order, asc: q.key]) @@ -327,7 +325,7 @@ defmodule Gamend.Quests do def active_quests_for_event(event) when is_binary(event) do Gamend.Cache.cached( {:quests, :for_event, event, quests_version()}, - [ttl: @cache_ttl_ms], + [ttl: Gamend.Cache.ttl()], fn -> Enum.filter(active_quests(), fn quest -> Enum.any?(quest.objectives, &(&1.event == event)) diff --git a/apps/gamend_core/lib/gamend/ready_checks.ex b/apps/gamend_core/lib/gamend/ready_checks.ex index ab96a0649..1540607a8 100644 --- a/apps/gamend_core/lib/gamend/ready_checks.ex +++ b/apps/gamend_core/lib/gamend/ready_checks.ex @@ -56,8 +56,6 @@ defmodule Gamend.ReadyChecks do alias Gamend.ReadyChecks.Participant alias Gamend.Repo - @pubsub Gamend.PubSub - @type subject :: Lobby.t() | Party.t() | :matchmaking @type scope :: :match | :party @type answer :: boolean() @@ -194,7 +192,7 @@ defmodule Gamend.ReadyChecks do metadata: Keyword.get(opts, :metadata, %{}) } - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> with {:ok, check} <- %Check{} |> Check.changeset(check_attrs) |> Repo.insert(), :ok <- insert_participants(check, user_ids, pre_ready, tickets) do check @@ -801,16 +799,14 @@ defmodule Gamend.ReadyChecks do # every member (and, for lobbies, spectator) sees one event; matchmaking # checks have no shared topic yet, so they fan out per user. defp broadcast(%Check{lobby_id: lobby_id} = check, event) when is_binary(lobby_id) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "lobby:#{lobby_id}", {:ready_check_event, event, check} ) end defp broadcast(%Check{party_id: party_id} = check, event) when is_binary(party_id) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "party:#{party_id}", {:ready_check_event, event, check} ) @@ -820,8 +816,7 @@ defmodule Gamend.ReadyChecks do check |> load_participants() |> Enum.each(fn participant -> - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "matchmaking:user:#{participant.user_id}", {:ready_check_event, event, check} ) diff --git a/apps/gamend_core/lib/gamend/realtime.ex b/apps/gamend_core/lib/gamend/realtime.ex index a8f6eedb0..8411632f8 100644 --- a/apps/gamend_core/lib/gamend/realtime.ex +++ b/apps/gamend_core/lib/gamend/realtime.ex @@ -35,8 +35,7 @@ defmodule Gamend.Realtime do if Map.has_key?(Declarations.realtime_events(), event) do topic = "user:#{user_id}" - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( topic, {:plugin_event, event, payload} ) diff --git a/apps/gamend_core/lib/gamend/repo.ex b/apps/gamend_core/lib/gamend/repo.ex index 23a8de11b..7f98d1adf 100644 --- a/apps/gamend_core/lib/gamend/repo.ex +++ b/apps/gamend_core/lib/gamend/repo.ex @@ -50,7 +50,7 @@ defmodule Gamend.Repo do @spec durable_transaction((-> result), keyword()) :: {:ok, result} | {:error, term()} when result: term() def durable_transaction(fun, opts \\ []) when is_function(fun, 0) do - transaction( + Gamend.AfterCommit.transaction( fn -> if postgres?() do query!("SET LOCAL synchronous_commit = on", []) diff --git a/apps/gamend_core/lib/gamend/repo/advisory_lock.ex b/apps/gamend_core/lib/gamend/repo/advisory_lock.ex index 7370c3b0e..00cacda92 100644 --- a/apps/gamend_core/lib/gamend/repo/advisory_lock.ex +++ b/apps/gamend_core/lib/gamend/repo/advisory_lock.ex @@ -19,9 +19,10 @@ defmodule Gamend.Repo.AdvisoryLock do ## Usage - Always call within a `Repo.transaction`: + Always call within a transaction, opened through `Gamend.AfterCommit` so + broadcasts inside wait for the commit (or use `Gamend.Lock.serialize/3`): - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> AdvisoryLock.lock(:lobby, lobby.id) count = count_members(lobby.id) if count >= lobby.max_users, do: Repo.rollback(:full) @@ -99,6 +100,37 @@ defmodule Gamend.Repo.AdvisoryLock do maybe_advisory_lock(namespace_id(namespace), hash_resource_id(resource_id)) end + @doc """ + Takes the session-level lock for `(namespace, resource_id)` on the current + connection, waiting as long as it takes. It is held until `unlock_session/2` + or until the connection closes, not until a transaction ends, for a job that + commits many transactions of its own under one lock (`Gamend.Lock.exclusive/3`). + + Postgres only. Call it inside `Repo.checkout/2`, so the lock, the work and + the unlock share one connection. It shares its key space with `lock/2`. + """ + @spec lock_session(atom() | String.t(), String.t()) :: :ok + def lock_session(namespace, resource_id) when is_binary(resource_id) do + Gamend.Repo.query!( + "SELECT pg_advisory_lock($1, $2)", + [namespace_id(namespace), hash_resource_id(resource_id)], + timeout: :infinity + ) + + :ok + end + + @doc "Releases a lock `lock_session/2` took on this connection." + @spec unlock_session(atom() | String.t(), String.t()) :: :ok + def unlock_session(namespace, resource_id) when is_binary(resource_id) do + Gamend.Repo.query!("SELECT pg_advisory_unlock($1, $2)", [ + namespace_id(namespace), + hash_resource_id(resource_id) + ]) + + :ok + end + @doc """ The integer namespace `pg_advisory_xact_lock` is called with. diff --git a/apps/gamend_core/lib/gamend/retention.ex b/apps/gamend_core/lib/gamend/retention.ex index ccb893dbe..685dcddcc 100644 --- a/apps/gamend_core/lib/gamend/retention.ex +++ b/apps/gamend_core/lib/gamend/retention.ex @@ -43,7 +43,11 @@ defmodule Gamend.Retention do on its own window, `GAMEND_RETENTION_ABANDONED_PARTY_MINUTES` (15). Expired IP bans, OAuth sessions older than a day, user tokens past their own - context's validity, and stored avatars whose owner no longer exists are always + context's validity, personal API tokens that can no longer authenticate + (expired, or older than their owner's last credential change), login + lockouts whose window and lock have run out, accounts past the deletion date + their owner's request set (`GAMEND_AUTH_DELETION_GRACE_DAYS`), and stored + avatars whose owner no longer exists are always removed (independent of the env vars above). Deletes are idempotent, so running on several instances at once is harmless; each class is batched and failure-isolated, and emits `[:gamend, :retention, :pruned]` telemetry with @@ -69,7 +73,7 @@ defmodule Gamend.Retention do require Logger alias Gamend.Accounts - alias Gamend.Accounts.{User, UserToken} + alias Gamend.Accounts.{ApiTokens, LoginLockouts, User, UserToken} alias Gamend.ClientLogs alias Gamend.ClientLogs.Session, as: ClientSession alias Gamend.ClientLogs.SessionLobby @@ -82,25 +86,35 @@ defmodule Gamend.Retention do alias Gamend.Repo alias Gamend.Storage - # First run shortly after boot, then every 6 hours. + # First run shortly after boot, then every `interval_hours`. @initial_delay_ms :timer.minutes(5) - @interval_ms :timer.hours(6) + + # The classes that free live game state: a seat, a lobby, a party. Their + # windows are minutes long, so they also run on a short cycle of their own + # (`live_interval_seconds`). Swept only with everything else, every six hours, + # a 15-minute window let a disconnected player sit on `already_in_lobby` and + # a dead lobby stay listed for up to six hours longer than it said. + @live_classes [ + :offline_lobby_memberships, + :offline_party_memberships, + :abandoned_parties, + :lobbies + ] # OAuth sessions are ephemeral handshake state (seconds-to-minutes of use); # always prune stale rows so the table can't grow unbounded. @oauth_session_ttl_days 1 - # Rows deleted per statement. The sweep runs against a live database: on - # SQLite one large DELETE holds the write lock long enough to stall gameplay - # writes, and on Postgres it bloats a single transaction. - @batch 500 + # Rows deleted per statement come from `batch_size`. The sweep runs against a + # live database: on SQLite one large DELETE holds the write lock long enough + # to stall gameplay writes, and on Postgres it bloats a single transaction. # How long a stored avatar is left alone before "its owner does not exist" is # read as orphaned rather than as a write still in progress. @orphan_avatar_grace_minutes 60 - # Ceiling on how much of the `avatars/` prefix one sweep walks (`@batch` per - # page). A run that hits it says so and resumes from the start six hours later. + # Ceiling on how much of the `avatars/` prefix one sweep walks (`batch_size` per + # page). A run that hits it says so and resumes from the start at the next sweep. @orphan_avatar_max_pages 20 # Invites and join requests are only garbage once they stop being actionable. @@ -130,6 +144,7 @@ defmodule Gamend.Retention do @impl true def init(_opts) do Process.send_after(self(), :prune, @initial_delay_ms) + schedule_live() {:ok, @never_run} end @@ -167,12 +182,31 @@ defmodule Gamend.Retention do @impl true def handle_info(:prune, _state) do state = sweep() - Process.send_after(self(), :prune, @interval_ms) + Process.send_after(self(), :prune, :timer.hours(max(config(:interval_hours), 1))) + {:noreply, state} + end + + # Not recorded as the last run: `status/0` describes a full sweep, and this + # one touches four classes. Each class still emits its own telemetry. + def handle_info(:prune_live, state) do + _ = prune_live() + schedule_live() {:noreply, state} end def handle_info(_msg, state), do: {:noreply, state} + # 0 folds the live classes back into the full sweep only. + defp schedule_live do + case config(:live_interval_seconds) do + seconds when is_integer(seconds) and seconds > 0 -> + Process.send_after(self(), :prune_live, :timer.seconds(seconds)) + + _off -> + :ok + end + end + defp sweep do started = System.monotonic_time(:millisecond) results = prune_all() @@ -235,7 +269,7 @@ defmodule Gamend.Retention do # would drop core's pruning and leave the table growing, which is the # failure this module exists to prevent. |> Map.merge(Map.drop(registered_classes(), Map.keys(core_classes()))) - |> Map.new(fn {class, fun} -> {class, run_class(class, fun)} end) + |> Map.new(&run_class/1) pruned = results |> Map.values() |> Enum.sum() @@ -246,6 +280,23 @@ defmodule Gamend.Retention do results end + @doc """ + Runs only the classes that free live game state: offline lobby and party + seats, abandoned parties, abandoned lobbies. What the short cycle + (`live_interval_seconds`) runs between full sweeps. + """ + @spec prune_live() :: %{atom() => non_neg_integer()} + def prune_live do + results = Map.new(Map.take(core_classes(), @live_classes), &run_class/1) + pruned = results |> Map.values() |> Enum.sum() + + if pruned > 0 do + Logger.info("retention released live state: #{inspect(results)}") + end + + results + end + # Core's own classes. Separate from `prune_all/0` so a registered class can be # checked against them by name. defp core_classes do @@ -264,6 +315,9 @@ defmodule Gamend.Retention do end, expired_ip_bans: &prune_expired_ip_bans/0, expired_user_tokens: &prune_expired_user_tokens/0, + login_lockouts: fn -> delete_in_batches(LoginLockouts.expired_query()) end, + scheduled_deletions: &delete_due_accounts/0, + dead_api_tokens: &prune_dead_api_tokens/0, lobby_snapshots: &prune_lobby_snapshots/0, client_sessions: &prune_client_sessions/0, lobby_snapshot_blobs: &prune_lobby_snapshot_blobs/0, @@ -532,7 +586,7 @@ defmodule Gamend.Retention do where: u.id not in subquery(from(p in Purchase, select: p.user_id)), where: u.id not in subquery(from(e in Entitlement, select: e.user_id)), where: ^identity_condition(kind), - limit: @batch + limit: ^batch() ) |> Repo.all() end @@ -565,6 +619,15 @@ defmodule Gamend.Retention do Enum.count(users, fn user -> match?({:ok, _}, Accounts.delete_user(user)) end) end + # Accounts their owners deleted, once `auth.deletion_grace_days` has run out. + # No exemptions: the owner asked. A batch a sweep, like the other user sweeps. + defp delete_due_accounts do + Accounts.due_deletions_query() + |> limit(^batch()) + |> Repo.all() + |> delete_users() + end + # Object storage neither cascades nor takes part in the deletion transaction. # `Accounts.delete_user/1` drops the `avatars//` prefix itself, but # anything that writes an object for an account that is already gone leaves @@ -579,7 +642,7 @@ defmodule Gamend.Retention do # after `@orphan_avatar_grace_minutes`, so an object mid-write is never # mistaken for an orphan. # - # Walked a page at a time so each pass asks the database about at most `@batch` + # Walked a page at a time so each pass asks the database about at most `batch_size` # ids. Both backends list in key order, so a single page would only ever see # the users whose ids sort first and an orphan past it would never be reached; # the offset advances by what the page left behind, since deleting from the @@ -591,11 +654,11 @@ defmodule Gamend.Retention do end defp sweep_avatar_page(offset, deleted, pages_left, cutoff) do - page = Storage.list_objects(prefix: "avatars/", offset: offset, limit: @batch) + page = Storage.list_objects(prefix: "avatars/", offset: offset, limit: batch()) removed = delete_ownerless_avatars(page, cutoff) cond do - length(page) < @batch -> + length(page) < batch() -> deleted + removed pages_left <= 1 -> @@ -697,7 +760,7 @@ defmodule Gamend.Retention do where: u.is_online == false, where: is_nil(u.last_seen_at) or u.last_seen_at <= ^cutoff, order_by: [asc: u.id], - limit: @batch + limit: ^batch() ) |> Repo.all() |> Enum.count(&release_membership/1) @@ -744,7 +807,7 @@ defmodule Gamend.Retention do ) ), order_by: [asc: p.id], - limit: @batch + limit: ^batch() ) |> Repo.all() |> Enum.count(&disband_party/1) @@ -777,7 +840,7 @@ defmodule Gamend.Retention do where: u.is_online == false, where: is_nil(u.last_seen_at) or u.last_seen_at <= ^cutoff, order_by: [asc: u.id], - limit: @batch + limit: ^batch() ) |> Repo.all() |> Enum.count(&release_party_membership/1) @@ -847,12 +910,12 @@ defmodule Gamend.Retention do end defp reap_lobbies(query, acc) do - lobbies = Repo.all(from(l in query, limit: @batch)) + lobbies = Repo.all(from(l in query, limit: ^batch())) deleted = Enum.count(lobbies, &reap_lobby/1) cond do deleted == 0 -> acc - length(lobbies) < @batch -> acc + deleted + length(lobbies) < batch() -> acc + deleted true -> reap_lobbies(query, acc + deleted) end end @@ -869,27 +932,27 @@ defmodule Gamend.Retention do # One class raising must not cost the whole sweep: every other table still # gets pruned, and the failure is logged rather than swallowed. - defp run_class(class, fun) do + defp run_class({class, fun}) do count = fun.() :telemetry.execute([:gamend, :retention, :pruned], %{count: count}, %{class: class}) - count + {class, count} rescue error -> Logger.error("retention class #{class} failed: #{Exception.message(error)}") - 0 + {class, 0} end # Both adapters reject `DELETE ... LIMIT`, so each pass selects a bounded set # of ids and deletes those. defp delete_in_batches(queryable, acc \\ 0) do - ids = Repo.all(from(r in exclude(queryable, :select), select: r.id, limit: @batch)) + ids = Repo.all(from(r in exclude(queryable, :select), select: r.id, limit: ^batch())) if ids == [] do acc else {count, _} = Repo.delete_all(from(r in queryable, where: r.id in ^ids)) - if length(ids) < @batch, do: acc + count, else: delete_in_batches(queryable, acc + count) + if length(ids) < batch(), do: acc + count, else: delete_in_batches(queryable, acc + count) end end @@ -898,6 +961,11 @@ defmodule Gamend.Retention do # its validity is dead weight, not a policy choice. defp prune_expired_user_tokens, do: delete_in_batches(UserToken.expired_query()) + # A personal API token past its expiry, or made before its owner's last + # password or email change, can never authenticate again. Same reasoning as + # above: nothing to configure. + defp prune_dead_api_tokens, do: delete_in_batches(ApiTokens.dead_query()) + # Resolved invites and join requests are a log of past social interactions; # pending ones are live UI and are never touched. updated_at is when the row # was resolved. @@ -1074,5 +1142,26 @@ defmodule Gamend.Retention do "0 keeps forever. Below 60 the admin retention cohorts go blank." ) + setting(:interval_hours, :integer, + default: 6, + doc: "Hours between full retention sweeps. The first runs five minutes after boot." + ) + + setting(:live_interval_seconds, :integer, + default: 60, + doc: + "Seconds between sweeps of the classes that free live state: offline lobby and " <> + "party seats, abandoned parties, abandoned lobbies. 0 leaves them to the full sweep." + ) + + setting(:batch_size, :integer, + default: 500, + doc: + "Rows deleted per statement. Lower it if a sweep stalls gameplay writes on SQLite, " <> + "where each statement holds the write lock." + ) + defp config(key), do: Gamend.Settings.get(__MODULE__, key) + + defp batch, do: max(config(:batch_size), 1) end diff --git a/apps/gamend_core/lib/gamend/signaling.ex b/apps/gamend_core/lib/gamend/signaling.ex index caac400a7..11c7472a9 100644 --- a/apps/gamend_core/lib/gamend/signaling.ex +++ b/apps/gamend_core/lib/gamend/signaling.ex @@ -41,8 +41,6 @@ defmodule Gamend.Signaling do alias Gamend.Lobbies alias Gamend.Presence - @stats_cache_ttl_ms 60_000 - @type room_id :: String.t() @type user_id :: String.t() @type topology :: :mesh | :star @@ -234,7 +232,7 @@ defmodule Gamend.Signaling do peers_connected: non_neg_integer() } def stats do - Gamend.Cache.cached({:signaling, :stats}, [ttl: @stats_cache_ttl_ms], fn -> + Gamend.Cache.cached({:signaling, :stats}, [ttl: Gamend.Cache.ttl()], fn -> peer_counts = Lobbies.webrtc_enabled_lobby_ids() |> Enum.map(fn room_id -> room_id |> topic() |> Presence.list() |> map_size() end) @@ -261,8 +259,7 @@ defmodule Gamend.Signaling do {:ok, from_role} <- fetch_peer(connected, from), {:ok, to_role} <- fetch_peer(connected, to), :ok <- allow_pair(cfg.topology, from_role, to_role) do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( inbox(room_id, to), {:signaling_relay, type, from, payload} ) @@ -282,8 +279,7 @@ defmodule Gamend.Signaling do {:ok, from_role} <- fetch_peer(connected, from), :ok <- allow_broadcast(cfg.topology, from_role) do for {user_id, _role} <- connected, user_id != from do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( inbox(room_id, user_id), {:signaling_relay, type, from, payload} ) @@ -299,8 +295,7 @@ defmodule Gamend.Signaling do Logger.info("Signaling: closing room=#{room_id}") for {user_id, _role} <- peers(room_id) do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( inbox(room_id, user_id), {:signaling_relay, :room_closed, nil, %{room_id: room_id}} ) diff --git a/apps/gamend_core/lib/gamend/signals.ex b/apps/gamend_core/lib/gamend/signals.ex index 5d59a24ac..8a2425a9b 100644 --- a/apps/gamend_core/lib/gamend/signals.ex +++ b/apps/gamend_core/lib/gamend/signals.ex @@ -50,8 +50,7 @@ defmodule Gamend.Signals do """ @spec emit(String.t(), String.t(), payload()) :: :ok def emit(plugin, name, payload \\ nil) do - Phoenix.PubSub.broadcast( - Gamend.PubSub, + Gamend.Broadcast.publish( topic(plugin, name), {:gd_signal, plugin, name, payload} ) diff --git a/apps/gamend_core/lib/gamend/storage.ex b/apps/gamend_core/lib/gamend/storage.ex index 1e7742d3a..ea858a0a5 100644 --- a/apps/gamend_core/lib/gamend/storage.ex +++ b/apps/gamend_core/lib/gamend/storage.ex @@ -92,6 +92,22 @@ defmodule Gamend.Storage do doc: "CDN or base URL serving stored objects, whichever backend is behind it." ) + setting(:upload_ttl_seconds, :integer, + default: 600, + doc: + "How long an upload ticket stays valid, in seconds. Raise it for large uploads " <> + "over slow connections." + ) + + # An S3 bucket with no `public_url` is private, and its objects are reached + # through `/storage/`, which redirects to a link signed for this long. + setting(:signed_url_seconds, :integer, + default: 3600, + doc: + "Lifetime of the signed link /storage/ redirects to, for an S3 bucket with " <> + "no public_url. S3 caps it at 604800 (7 days)." + ) + @adapters %{local: Gamend.Storage.Local, s3: Gamend.Storage.S3} @doc "The configured backend module (defaults to `Gamend.Storage.Local`)." @@ -134,10 +150,25 @@ defmodule Gamend.Storage do @spec exists?(Adapter.key()) :: boolean() def exists?(key), do: adapter().exists?(key) - @doc "A readable URL for `key` (public or signed, backend-dependent)." + @doc """ + A readable URL for `key`, safe to store: it does not expire. + + For an S3 bucket with no `public_url` that is `/storage/`, which + redirects to a freshly signed link. Pass `signed: true` for the signed link + itself, which lasts `signed_url_seconds` and must not be stored. + """ @spec url(Adapter.key(), keyword()) :: String.t() def url(key, opts \\ []), do: adapter().url(key, opts) + @doc "Seconds an upload ticket stays valid (`upload_ttl_seconds`)." + @spec upload_ttl_seconds() :: pos_integer() + def upload_ttl_seconds, do: max(Gamend.Settings.get(__MODULE__, :upload_ttl_seconds), 1) + + @doc "Seconds a signed read link stays valid (`signed_url_seconds`), at most S3's 7 days." + @spec signed_url_seconds() :: pos_integer() + def signed_url_seconds, + do: Gamend.Settings.get(__MODULE__, :signed_url_seconds) |> max(1) |> min(604_800) + @doc "An upload ticket for the client (see the module doc)." @spec presigned_upload(Adapter.key(), keyword()) :: {:ok, Adapter.presigned()} | {:error, term()} diff --git a/apps/gamend_core/lib/gamend/storage/local.ex b/apps/gamend_core/lib/gamend/storage/local.ex index 072855b17..2aa6ae25b 100644 --- a/apps/gamend_core/lib/gamend/storage/local.ex +++ b/apps/gamend_core/lib/gamend/storage/local.ex @@ -11,9 +11,6 @@ defmodule Gamend.Storage.Local do @behaviour Gamend.Storage.Adapter - # How long an upload ticket is nominally valid (seconds) — advisory for clients. - @upload_ttl 600 - @impl true def put(key, data, _opts) do path = path_for(key) @@ -53,7 +50,7 @@ defmodule Gamend.Storage.Local do url: "#{base_url()}/storage/upload?key=#{URI.encode_www_form(key)}", headers: headers, key: key, - expires_in: @upload_ttl + expires_in: Gamend.Storage.upload_ttl_seconds() }} end diff --git a/apps/gamend_core/lib/gamend/storage/s3.ex b/apps/gamend_core/lib/gamend/storage/s3.ex index 781b2a36f..06f0e0e9f 100644 --- a/apps/gamend_core/lib/gamend/storage/s3.ex +++ b/apps/gamend_core/lib/gamend/storage/s3.ex @@ -47,25 +47,33 @@ defmodule Gamend.Storage.S3 do match?({:ok, _}, bucket() |> S3.head_object(key) |> request()) end + # With no `public_url` the bucket is private. A signed link expires, so the URL + # handed out to be stored is our own `/storage/`, which redirects to a + # fresh one: a signed link saved as a player's `profile_url` stopped loading an + # hour after the upload. @impl true def url(key, opts) do case Gamend.Settings.get(Gamend.Storage, :public_url) do nil -> - {:ok, url} = - S3.presigned_url(aws_config(), :get, bucket(), key, - expires_in: Keyword.get(opts, :expires_in, 3600) - ) - - url + if Keyword.get(opts, :signed, false), do: signed_url(key), else: "/storage/#{key}" base -> "#{String.trim_trailing(base, "/")}/#{key}" end end + defp signed_url(key) do + {:ok, url} = + S3.presigned_url(aws_config(), :get, bucket(), key, + expires_in: Gamend.Storage.signed_url_seconds() + ) + + url + end + @impl true def presigned_upload(key, opts) do - expires_in = Keyword.get(opts, :expires_in, 600) + expires_in = Keyword.get_lazy(opts, :expires_in, &Gamend.Storage.upload_ttl_seconds/0) content_type = Keyword.get(opts, :content_type) case S3.presigned_url(aws_config(), :put, bucket(), key, expires_in: expires_in) do diff --git a/apps/gamend_core/lib/gamend/tournaments.ex b/apps/gamend_core/lib/gamend/tournaments.ex index efe1f1a74..16693f1a1 100644 --- a/apps/gamend_core/lib/gamend/tournaments.ex +++ b/apps/gamend_core/lib/gamend/tournaments.ex @@ -33,42 +33,18 @@ defmodule Gamend.Tournaments do alias Crontab.CronExpression.Parser, as: CronParser alias Crontab.Scheduler, as: CronScheduler - @pubsub Gamend.PubSub - # Cached reads keyed by a version counter bumped on every tournament-row write, # so any write (single or bulk) invalidates all cached tournament rows at once. - @tournament_cache_ttl_ms 60_000 defp tournament_cache_version, do: Gamend.Cache.get!({:tournaments, :version}) || 1 defp bump_tournament_cache, do: Gamend.Cache.bump_version({:tournaments, :version}) # Hook dispatches and broadcasts must never run while a lock/transaction is # open: the hook runs in another process, and anything it writes contends # with the very transaction that spawned it (a game resolving a match from - # `tournament_match_ready` would block on the draw's advisory lock). Effects - # are therefore queued while in a transaction and flushed after it commits — - # which also means observers never see uncommitted state. - @deferred_key {__MODULE__, :deferred_effects} - - defp defer(fun) when is_function(fun, 0) do - if Repo.in_transaction?() do - Process.put(@deferred_key, [fun | Process.get(@deferred_key, [])]) - :ok - else - fun.() - :ok - end - end - - defp flush_deferred do - if Repo.in_transaction?() do - :ok - else - effects = @deferred_key |> Process.get([]) |> Enum.reverse() - Process.delete(@deferred_key) - Enum.each(effects, & &1.()) - :ok - end - end + # `tournament_match_ready` would block on the draw's advisory lock). They + # wait for the commit (`Gamend.AfterCommit`), which also means observers + # never see uncommitted state, and a rollback drops them. + defp defer(fun) when is_function(fun, 0), do: Gamend.AfterCommit.defer(fun) # ── CRUD (admin / hooks) ────────────────────────────────────────────────── @@ -136,7 +112,7 @@ defmodule Gamend.Tournaments do @spec get_tournament(Ecto.UUID.t()) :: Tournament.t() | nil @decorate cacheable( key: {:tournaments, :get, tournament_cache_version(), id}, - opts: [ttl: @tournament_cache_ttl_ms] + opts: [ttl: Gamend.Cache.ttl()] ) def get_tournament(id) when is_binary(id), do: Repo.get(Tournament, id) @@ -437,9 +413,7 @@ defmodule Gamend.Tournaments do """ @spec advance_lifecycle(Tournament.t(), DateTime.t()) :: Tournament.t() def advance_lifecycle(%Tournament{} = tournament, now \\ DateTime.utc_now()) do - result = do_advance_lifecycle(tournament, now) - flush_deferred() - result + do_advance_lifecycle(tournament, now) end defp do_advance_lifecycle(%Tournament{} = tournament, now) do @@ -498,7 +472,10 @@ defmodule Gamend.Tournaments do """ @spec tick(DateTime.t()) :: :ok def tick(now \\ DateTime.utc_now()) do - Gamend.Lock.serialize(:tournaments_tick, "global", fn -> + # Once cluster-wide, but not one transaction: each tournament's draw and + # sweep commits on its own, so a tick with many due never holds the + # database (on SQLite, its only write lock) for all of them at once. + Gamend.Lock.exclusive(:tournaments_tick, "global", fn -> from(t in Tournament, where: t.state in ["scheduled", "registration", "running"]) |> Repo.all() |> Enum.each(fn tournament -> @@ -513,7 +490,6 @@ defmodule Gamend.Tournaments do spawn_missed_recurrences(now) end) - flush_deferred() :ok end @@ -524,7 +500,7 @@ defmodule Gamend.Tournaments do # Re-read inside the lock: a concurrent caller must not draw twice. case Repo.get(Tournament, tournament.id) do %Tournament{state: "registration"} = tournament -> - {:ok, tournament} = Repo.transaction(fn -> do_draw(tournament, now) end) + {:ok, tournament} = Gamend.AfterCommit.transaction(fn -> do_draw(tournament, now) end) after_draw(tournament, now) tournament @@ -711,9 +687,7 @@ defmodule Gamend.Tournaments do match_payload(tournament, match), winner ]) do - result = internal_resolve(tournament, match, winner, %{}) - flush_deferred() - result + internal_resolve(tournament, match, winner, %{}) else {:error, _} = err -> err nil -> {:error, :not_found} @@ -754,7 +728,7 @@ defmodule Gamend.Tournaments do now = DateTime.utc_now() {:ok, match} = - Repo.transaction(fn -> + Gamend.AfterCommit.transaction(fn -> {:ok, match} = match |> Match.changeset(%{ @@ -1342,8 +1316,7 @@ defmodule Gamend.Tournaments do end defp broadcast_user(user_id, event, payload) do - Phoenix.PubSub.broadcast( - @pubsub, + Gamend.Broadcast.publish( "tournaments:user:#{user_id}", {:tournament_event, event, payload} ) diff --git a/apps/gamend_core/lib/gamend/tournaments/ticker.ex b/apps/gamend_core/lib/gamend/tournaments/ticker.ex index 7c92ba0ce..55419f5ce 100644 --- a/apps/gamend_core/lib/gamend/tournaments/ticker.ex +++ b/apps/gamend_core/lib/gamend/tournaments/ticker.ex @@ -10,9 +10,20 @@ defmodule Gamend.Tournaments.Ticker do use GenServer require Logger - @interval_ms :timer.seconds(30) @initial_delay_ms :timer.seconds(5) + use Gamend.Settings.Provider, + app: :gamend_core, + group: :tournaments, + label: "Tournaments" + + setting(:tick_interval_seconds, :integer, + default: 30, + doc: + "Seconds between tournament ticks: state transitions, match-ready, deadline " <> + "sweeps and recurrence. A round can start or time out up to this late." + ) + def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @@ -36,7 +47,8 @@ defmodule Gamend.Tournaments.Ticker do e -> Logger.error("tournaments tick failed: #{Exception.message(e)}") end - Process.send_after(self(), :tick, @interval_ms) + seconds = max(Gamend.Settings.get(__MODULE__, :tick_interval_seconds), 1) + Process.send_after(self(), :tick, :timer.seconds(seconds)) {:noreply, state} end end diff --git a/apps/gamend_core/mix.lock b/apps/gamend_core/mix.lock index d1541fbea..11be67540 100644 --- a/apps/gamend_core/mix.lock +++ b/apps/gamend_core/mix.lock @@ -14,7 +14,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.24.1", "26665565f075aaf5f83a76a409ffdef4d2f0a2f3c6840bbf0995993fbea2ad32", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "681ca576c74a94944b962eeb7e0cf19aaea517decafd3213afb403ac8f4cd2e3"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.25.0", "309898d694b17a8ca8cd88d648d3a7fe8479b04ea6831a18d97d56ed3541031b", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7da65c7af38dccf228320db32f93ae49650b0afdd850a09fd2fb191554b3faf5"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_aws": {:hex, :ex_aws, "2.7.0", "e6bfd4b5fb8c791aa6c7d57fc7c45f050bb89de9576f1f6104aa974b234c01ec", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bfe9d744d4fd4c1f40314ee7fab504d5547d1f01cd377fff1568cbe630b06d65"}, @@ -22,29 +22,29 @@ "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "ex_hash_ring": {:hex, :ex_hash_ring, "7.0.0", "2d92d90669019536abaab7db6ccf00cb33c323e43a1fb44f1f16177a3d777a99", [:mix], [], "hexpm", "9f61f33f043a69e9df9febe6df05ddb0ec78227a43aad8407503e2cd81715c5b"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, - "exqlite": {:hex, :exqlite, "0.40.0", "d63cb394a588ecbe73f19c56fbbcf397bf6488b5872fcddc9383fb375cf6131d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f83350f2d29a38be1fd38f39081dd36f41fc51bbacf0f4c2927d01308ae331d0"}, + "exqlite": {:hex, :exqlite, "0.41.0", "f7b6d9730d19efd8a2c9d4624172e82d4f98f2094743429c111972994967b4e2", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a7e9b6bed529ab72aa07ed2a925ac109c27e6877a7a8af252361c396a4192855"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, - "h2": {:hex, :h2, "0.12.0", "f393539ee2728f8118fb2024b6d5f3e2c45e40ceb31b18b4e9bf5e50d028f80f", [:rebar3], [], "hexpm", "beaafc93c54cdc5d623247334d3970cdf4bc66b6b8b296b74ba1d7c7513c3dfc"}, - "hackney": {:hex, :hackney, "4.7.4", "8fe2ddaa3ca27de99d68e682d72b66d07d2331da680f77c8000580a0122c69e6", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.5", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "d07d7e1358353ab6cc75132f058c155287f3e013d43f709fbb79d79eeab98195"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, + "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "httpoison": {:hex, :httpoison, "3.0.0", "8566a933bb9175236d1ec335978445b67cd1f5b5d3ead6ca4b80be469d41f5d9", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "9130197b7658901c493d6fcfb842fb9676300fa8a6c8ed058c8889cf1a77f3c2"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lumis": {:hex, :lumis, "0.8.0", "981c61af9f79f30ba2fed48dd5d0b2a1a021e5555e4150442be961978e56513f", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d5b71a5b082f32fc1d007d02f03922db95b9caec335b03a1985edb726c59f63e"}, + "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, - "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, - "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, + "mdex": {:hex, :mdex, "0.14.0", "6bb28b49682465f802e9a129db4319e48a34edf9626e8186c1d5ea29de3f2a81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.9", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.9", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "60e591041b7f5811004cc275e8d3ae1dffb6958159812402427264b444de5080"}, + "mdex_native": {:hex, :mdex_native, "0.2.9", "898cc5514d556c4d46fa43035d6fc925116413bb3821c5a71d4bd42f87a90b85", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "7470e6051b6193c620be02acbc12f1acd758250a60c13695ed36ebda512267ac"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "nebulex": {:hex, :nebulex, "3.0.4", "b55f73e5cd455a57a4dd25df9099806b9bae2384653a76a2fa2fa3184b3abb57", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "446afc6d3f701ba991f1fb0eee36c600f888530e52f30f80b308480ad65faab6"}, "nebulex_distributed": {:hex, :nebulex_distributed, "3.2.3", "d45e28ee7d88d5a49dd4ee71aa7c2d0be3337b53875a78245bcb021bd46d53e1", [:mix], [{:ex_hash_ring, "~> 6.0 or ~> 7.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:nebulex, "~> 3.0", [hex: :nebulex, repo: "hexpm", optional: false]}, {:nebulex_local, "~> 3.0", [hex: :nebulex_local, repo: "hexpm", optional: false]}, {:nebulex_streams, "~> 0.2", [hex: :nebulex_streams, repo: "hexpm", optional: false]}, {:partitioned_buffer, "~> 0.4", [hex: :partitioned_buffer, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "67a353fb5d67506767667d816d9f8a46a813549ba74e74bb0a1a0d83a078abff"}, "nebulex_local": {:hex, :nebulex_local, "3.0.0", "1aead00a59d2043f419440b4c55851d3585419ef8918c1f47148d8ae180e8179", [:mix], [{:ex2ms, "~> 1.7", [hex: :ex2ms, repo: "hexpm", optional: true]}, {:nebulex, "~> 3.0", [hex: :nebulex, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7a087d9f071369ba272cd688c2bc4b758926ab3a2e239bce1b529653a14bdad1"}, @@ -60,16 +60,16 @@ "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "pigeon": {:hex, :pigeon, "2.1.0", "993a253a1c9cddefc81bc252943a3eff35a1a8f36322eff0b5087fb16588dbec", [:mix], [{:goth, "~> 1.4.3", [hex: :goth, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:joken, "~> 2.1", [hex: :joken, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "710a7b8c896848a2bddd3e23018e54e12d6b577fa0d233fb638d5fbfa3406c89"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, - "quic": {:hex, :quic, "1.8.2", "c315176d2c4fad0725e52f2a8033b96d01c4fecfc7e6a7333615ff10041142a5", [:rebar3], [], "hexpm", "274d2f41ee9c00d8d6415248df9fb5637381fe69b5387771dc37e91038a65479"}, + "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, - "redix": {:hex, :redix, "1.9.1", "99473ccc538b97eb169beea133e788c236850e84ec3bcc785ef414ffca9fb2e2", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b873953d06f893f88bb92a515ff9f9c0b65662576de27fb810e76cff3609ebab"}, + "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, @@ -86,5 +86,5 @@ "uri_query": {:hex, :uri_query, "0.2.0", "0f5e0f7ea6d9e6a7fb4929a81df9ecd756e3c71bdee5c9bc14e57d90069a82f7", [:mix], [], "hexpm", "e99f50a6af7c6643dff948db152a6a420bfe446aaec7f0924cfcdb710c175e63"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, - "webtransport": {:hex, :webtransport, "0.4.5", "0e387202bbe707389fe81373ef8c56faa9d5aa321bb4800fa4765ee7c1399785", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 1.8.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "bcb512239e48e551d5bd5c667312a9a7de4f29b89d84b1d33c5af44e1f3f730d"}, + "webtransport": {:hex, :webtransport, "0.4.7", "8e0abd5875daab05c7020b8fc0c3b318fe93aa9329302fe5ecda2a0f953cf688", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "9d2fcbfc561a172cf6f2eef00b89fc89774c039ce850414356964c147707f85e"}, } diff --git a/apps/gamend_core/priv/repo/migrations/20260925090000_create_api_tokens.exs b/apps/gamend_core/priv/repo/migrations/20260925090000_create_api_tokens.exs new file mode 100644 index 000000000..de5917ace --- /dev/null +++ b/apps/gamend_core/priv/repo/migrations/20260925090000_create_api_tokens.exs @@ -0,0 +1,30 @@ +defmodule Gamend.Repo.Migrations.CreateApiTokens do + @moduledoc """ + Personal API tokens (`Gamend.Accounts.ApiTokens`): long-lived bearer tokens + for scripts and CI, created on the settings page. + + Only a SHA-256 of each token is stored, under a unique index, so a request + is one indexed lookup and a leaked database holds nothing a client could + send. `token_version` is the owner's at creation: a password or email change + bumps the user's, which retires every token made before it. + """ + use Ecto.Migration + + def change do + create table(:api_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :token_hash, :binary, null: false + add :hint, :string, null: false + add :token_version, :integer, null: false, default: 0 + add :expires_at, :utc_datetime + add :last_used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:api_tokens, [:token_hash]) + create index(:api_tokens, [:user_id]) + create index(:api_tokens, [:expires_at], where: "expires_at IS NOT NULL") + end +end diff --git a/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs b/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs new file mode 100644 index 000000000..868dc46ab --- /dev/null +++ b/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs @@ -0,0 +1,27 @@ +defmodule Gamend.Repo.Migrations.CreateLoginLockouts do + @moduledoc """ + Failed password sign-ins per email address (`Gamend.Accounts.LoginLockouts`), + and the lock they set once there are too many. + + Keyed by a SHA-256 of the normalized address rather than by user: an address + with no account counts and locks exactly like one that has an account, so a + lock never tells anyone which addresses are registered, and the table holds + no addresses. A row lives for one window of failures, or for the lock it set; + `Gamend.Retention` prunes it after. + """ + use Ecto.Migration + + def change do + create table(:login_lockouts) do + add :key_hash, :binary, null: false + add :failures, :integer, null: false, default: 0 + add :window_started_at, :utc_datetime, null: false + add :locked_until, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:login_lockouts, [:key_hash]) + create index(:login_lockouts, [:updated_at]) + end +end diff --git a/apps/gamend_core/priv/repo/migrations/20260925130100_add_deletion_scheduled_at_to_users.exs b/apps/gamend_core/priv/repo/migrations/20260925130100_add_deletion_scheduled_at_to_users.exs new file mode 100644 index 000000000..ccdef5320 --- /dev/null +++ b/apps/gamend_core/priv/repo/migrations/20260925130100_add_deletion_scheduled_at_to_users.exs @@ -0,0 +1,25 @@ +defmodule Gamend.Repo.Migrations.AddDeletionScheduledAtToUsers do + @moduledoc """ + `users.deletion_scheduled_at`: when an account whose owner asked to delete it + is actually deleted, with `GAMEND_AUTH_DELETION_GRACE_DAYS` set. NULL for + every account not waiting on that, so the index is partial: the retention + sweep that deletes due accounts reads only the rows that have a date. + """ + use Ecto.Migration + + def up do + alter table(:users) do + add :deletion_scheduled_at, :utc_datetime + end + + create index(:users, [:deletion_scheduled_at], where: "deletion_scheduled_at IS NOT NULL") + end + + def down do + drop index(:users, [:deletion_scheduled_at]) + + alter table(:users) do + remove :deletion_scheduled_at + end + end +end diff --git a/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs b/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs new file mode 100644 index 000000000..a7125b6a9 --- /dev/null +++ b/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs @@ -0,0 +1,57 @@ +defmodule Gamend.Repo.Migrations.StableStorageUrls do + @moduledoc """ + Rewrite stored object URLs that were signed S3 links to `/storage/`. + + On an S3 bucket with no `GAMEND_STORAGE_PUBLIC_URL`, `Gamend.Storage.url/1` + used to answer a link signed for an hour, and that link is what avatar and + icon uploads saved. Every such image stopped loading an hour after it was + uploaded. The URL handed out now is `/storage/`, which redirects to a + fresh signed link; this puts the rows already written on it. + + Only values carrying an S3 signature are touched, and the key is found by + its `//` segment, the way it was built. No-op on a deployment + that never had a private bucket. + """ + use Ecto.Migration + + import Ecto.Query + + @columns [ + {"users", :profile_url, "avatars"}, + {"groups", :icon_url, "icons/groups"}, + {"quests", :icon_url, "icons/quests"}, + {"leaderboards", :icon_url, "icons/leaderboards"}, + {"tournaments", :icon_url, "icons/tournaments"} + ] + + def up do + for {table, column, prefix} <- @columns do + rows = + repo().all( + from(r in table, + where: like(field(r, ^column), "%X-Amz-Signature=%"), + select: {type(r.id, :binary_id), field(r, ^column)} + ) + ) + + for {id, url} <- rows, key = storage_key(url, "#{prefix}/#{id}/") do + repo().update_all( + from(r in table, where: r.id == type(^id, :binary_id)), + set: [{column, "/storage/" <> key}] + ) + end + end + end + + def down, do: :ok + + defp storage_key(url, segment) do + case :binary.match(url, segment) do + {pos, _len} -> + url |> binary_part(pos, byte_size(url) - pos) |> String.split("?", parts: 2) |> hd() + + :nomatch -> + nil + end + end +end diff --git a/apps/gamend_core/test/gamend/accounts/api_token_cap_test.exs b/apps/gamend_core/test/gamend/accounts/api_token_cap_test.exs new file mode 100644 index 000000000..d11e72b9a --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/api_token_cap_test.exs @@ -0,0 +1,55 @@ +defmodule Gamend.Accounts.ApiTokenCapTest do + # `auth.api_token_max_days` is global Application config. + use Gamend.DataCase, async: false + + import Ecto.Query + + alias Gamend.Accounts + alias Gamend.Accounts.ApiToken + alias Gamend.Accounts.ApiTokens + alias Gamend.AccountsFixtures + alias Gamend.SettingsHelpers + + setup do + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, :api_token_max_days) end) + %{user: AccountsFixtures.user_fixture()} + end + + test "uncapped, a token may never expire" do + assert ApiToken.expiry_choices() == [30, 90, 365, nil] + end + + test "a cap trims the choices to it and takes away never", %{user: user} do + SettingsHelpers.put(:gamend_core, Accounts, :api_token_max_days, 180) + assert ApiToken.expiry_choices() == [30, 90, 180] + + assert {:ok, _, row} = ApiTokens.create(user, %{"name" => "ci", "expires_in_days" => 180}) + assert DateTime.diff(row.expires_at, DateTime.utc_now(), :day) in 179..180 + + assert {:error, changeset} = + ApiTokens.create(user, %{"name" => "forever", "expires_in_days" => nil}) + + assert "can't be blank" in errors_on(changeset).expires_in_days + + assert {:error, changeset} = + ApiTokens.create(user, %{"name" => "long", "expires_in_days" => 365}) + + assert errors_on(changeset).expires_in_days != [] + end + + test "a cap ends tokens made before it, counted from their creation", %{user: user} do + {:ok, token, row} = ApiTokens.create(user, %{"name" => "forever", "expires_in_days" => nil}) + made = DateTime.add(DateTime.utc_now(:second), -40, :day) + Repo.update_all(from(t in ApiToken, where: t.id == ^row.id), set: [inserted_at: made]) + + assert {:ok, _, _} = ApiTokens.verify(token) + + SettingsHelpers.put(:gamend_core, Accounts, :api_token_max_days, 30) + row = Repo.get!(ApiToken, row.id) + + assert ApiTokens.expires_at(row) == DateTime.add(made, 30, :day) + assert ApiTokens.expired?(row) + assert ApiTokens.verify(token) == :error + assert row.id in Repo.all(from(t in ApiTokens.dead_query(), select: t.id)) + end +end diff --git a/apps/gamend_core/test/gamend/accounts/api_tokens_test.exs b/apps/gamend_core/test/gamend/accounts/api_tokens_test.exs new file mode 100644 index 000000000..21561a3fa --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/api_tokens_test.exs @@ -0,0 +1,136 @@ +defmodule Gamend.Accounts.ApiTokensTest do + use Gamend.DataCase, async: true + + import Ecto.Query + + alias Gamend.Accounts + alias Gamend.Accounts.ApiToken + alias Gamend.Accounts.ApiTokens + alias Gamend.AccountsFixtures + + setup do + %{user: AccountsFixtures.user_fixture()} + end + + describe "create/2" do + test "answers the token once and stores only its hash", %{user: user} do + assert {:ok, "gamend_pat_" <> secret = token, row} = + ApiTokens.create(user, %{"name" => "ci", "expires_in_days" => 30}) + + assert byte_size(secret) >= 40 + assert row.name == "ci" + assert row.hint == String.slice(secret, 0, 6) + assert row.token_hash == :crypto.hash(:sha256, token) + refute Repo.get!(ApiToken, row.id).token_hash == token + assert DateTime.diff(row.expires_at, DateTime.utc_now(), :day) in 29..30 + end + + test "a token that never expires has no expires_at", %{user: user} do + assert {:ok, _token, row} = + ApiTokens.create(user, %{"name" => "forever", "expires_in_days" => nil}) + + assert row.expires_at == nil + end + + test "a name is required and the lifetime is one of the choices", %{user: user} do + assert {:error, changeset} = ApiTokens.create(user, %{"name" => " "}) + assert "can't be blank" in errors_on(changeset).name + + assert {:error, changeset} = + ApiTokens.create(user, %{"name" => "x", "expires_in_days" => 7}) + + assert errors_on(changeset).expires_in_days != [] + end + + test "stops at the per-user limit", %{user: user} do + # No other test reads this key, so the global override cannot leak. + Gamend.SettingsHelpers.put(:gamend_core, Gamend.Limits, :max_api_tokens_per_user, 1) + + on_exit(fn -> + Gamend.SettingsHelpers.delete(:gamend_core, Gamend.Limits, :max_api_tokens_per_user) + end) + + assert {:ok, _, _} = ApiTokens.create(user, %{"name" => "one"}) + assert {:error, :limit_reached} = ApiTokens.create(user, %{"name" => "two"}) + end + end + + describe "verify/1" do + test "a live token names its owner", %{user: user} do + {:ok, token, row} = ApiTokens.create(user, %{"name" => "ci"}) + + assert {:ok, verified, verified_row} = ApiTokens.verify(token) + assert verified.id == user.id + assert verified_row.id == row.id + end + + test "unknown, malformed and JWT-shaped strings do not verify" do + assert ApiTokens.verify("gamend_pat_nope") == :error + assert ApiTokens.verify("eyJhbGciOi.x.y") == :error + assert ApiTokens.verify(nil) == :error + end + + test "an expired token does not verify", %{user: user} do + {:ok, token, row} = ApiTokens.create(user, %{"name" => "old"}) + past = DateTime.utc_now() |> DateTime.add(-1, :minute) |> DateTime.truncate(:second) + Repo.update_all(from(t in ApiToken, where: t.id == ^row.id), set: [expires_at: past]) + + assert ApiTokens.verify(token) == :error + end + + test "a revoked token does not verify", %{user: user} do + {:ok, token, row} = ApiTokens.create(user, %{"name" => "gone"}) + + assert {:ok, _} = ApiTokens.revoke(user.id, row.id) + assert ApiTokens.verify(token) == :error + end + + test "a credential change retires every token made before it", %{user: user} do + {:ok, token, _row} = ApiTokens.create(user, %{"name" => "before"}) + + {:ok, {user, _expired}} = Accounts.revoke_all_tokens(user) + + assert ApiTokens.verify(token) == :error + + {:ok, fresh, _row} = ApiTokens.create(user, %{"name" => "after"}) + assert {:ok, _, _} = ApiTokens.verify(fresh) + end + end + + test "revoke/2 only reaches the owner's tokens", %{user: user} do + other = AccountsFixtures.user_fixture() + {:ok, _token, row} = ApiTokens.create(other, %{"name" => "theirs"}) + + assert ApiTokens.revoke(user.id, row.id) == {:error, :not_found} + assert Repo.get(ApiToken, row.id) + end + + test "list/2 and count/1 are the owner's, newest first", %{user: user} do + {:ok, _, first} = ApiTokens.create(user, %{"name" => "first"}) + {:ok, _, second} = ApiTokens.create(user, %{"name" => "second"}) + {:ok, _, _} = ApiTokens.create(AccountsFixtures.user_fixture(), %{"name" => "not mine"}) + + assert Enum.map(ApiTokens.list(user.id), & &1.id) == [second.id, first.id] + assert ApiTokens.count(user.id) == 2 + end + + test "dead_query/0 finds expired and superseded tokens, and nothing live", %{user: user} do + {:ok, _, live} = ApiTokens.create(user, %{"name" => "live"}) + {:ok, _, expired} = ApiTokens.create(user, %{"name" => "expired"}) + past = DateTime.utc_now() |> DateTime.add(-1, :minute) |> DateTime.truncate(:second) + Repo.update_all(from(t in ApiToken, where: t.id == ^expired.id), set: [expires_at: past]) + + other = AccountsFixtures.user_fixture() + {:ok, _, superseded} = ApiTokens.create(other, %{"name" => "superseded"}) + {:ok, _} = Accounts.revoke_all_tokens(other) + + dead = ApiTokens.dead_query() |> Repo.all() |> Enum.map(& &1.id) |> Enum.sort() + + assert dead == Enum.sort([expired.id, superseded.id]) + refute live.id in dead + + # Deletable on either adapter: no join at the top level. + assert {2, _} = Repo.delete_all(ApiTokens.dead_query()) + assert Repo.get(ApiToken, live.id) + end +end diff --git a/apps/gamend_core/test/gamend/accounts/deletion_grace_test.exs b/apps/gamend_core/test/gamend/accounts/deletion_grace_test.exs new file mode 100644 index 000000000..056f2c839 --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/deletion_grace_test.exs @@ -0,0 +1,59 @@ +defmodule Gamend.Accounts.DeletionGraceTest do + # Settings are global Application config. + use Gamend.DataCase, async: false + + alias Gamend.Accounts + alias Gamend.Accounts.User + alias Gamend.AccountsFixtures + alias Gamend.SettingsHelpers + + setup do + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, :deletion_grace_days) end) + %{user: AccountsFixtures.user_fixture()} + end + + test "with no grace period the account is deleted at once", %{user: user} do + assert {:ok, :deleted} = Accounts.request_deletion(user) + refute Repo.get(User, user.id) + end + + test "with a grace period the account is scheduled and signed out everywhere", + %{user: user} do + SettingsHelpers.put(:gamend_core, Accounts, :deletion_grace_days, 30) + token = Accounts.generate_user_session_token(user) + + assert {:ok, {:scheduled, scheduled, [_ | _]}} = Accounts.request_deletion(user) + assert DateTime.diff(scheduled.deletion_scheduled_at, DateTime.utc_now(), :day) in 29..30 + assert scheduled.token_version > user.token_version + assert Accounts.deletion_scheduled?(scheduled) + refute Accounts.get_user_by_session_token(token) + + # Asking again keeps the first date. + assert {:ok, {:scheduled, again, _}} = Accounts.request_deletion(scheduled) + assert again.deletion_scheduled_at == scheduled.deletion_scheduled_at + end + + test "cancelling keeps the account", %{user: user} do + SettingsHelpers.put(:gamend_core, Accounts, :deletion_grace_days, 30) + {:ok, {:scheduled, scheduled, _}} = Accounts.request_deletion(user) + + assert {:ok, kept} = Accounts.cancel_deletion(scheduled) + refute Accounts.deletion_scheduled?(kept) + refute Accounts.deletion_scheduled?(Accounts.get_user!(user.id)) + end + + test "retention deletes an account once its date has passed, and not before", + %{user: user} do + SettingsHelpers.put(:gamend_core, Accounts, :deletion_grace_days, 30) + {:ok, {:scheduled, _, _}} = Accounts.request_deletion(user) + + assert %{scheduled_deletions: 0} = Gamend.Retention.prune_all() + assert Repo.get(User, user.id) + + past = DateTime.add(DateTime.utc_now(:second), -1, :minute) + Repo.update_all(User, set: [deletion_scheduled_at: past]) + + assert %{scheduled_deletions: 1} = Gamend.Retention.prune_all() + refute Repo.get(User, user.id) + end +end diff --git a/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs b/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs new file mode 100644 index 000000000..76754a595 --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs @@ -0,0 +1,99 @@ +defmodule Gamend.Accounts.LoginLockoutsTest do + # Settings are global Application config. + use Gamend.DataCase, async: false + + import Ecto.Query + + alias Gamend.Accounts + alias Gamend.Accounts.LoginLockout + alias Gamend.AccountsFixtures + alias Gamend.SettingsHelpers + + @password AccountsFixtures.valid_user_password() + + setup do + SettingsHelpers.put(:gamend_core, Accounts, :lockout_attempts, 3) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, :lockout_attempts) end) + + user = AccountsFixtures.user_fixture() |> AccountsFixtures.set_password() + %{user: user} + end + + defp fail(email), do: Accounts.authenticate_by_password(email, "wrong password!") + + test "the failure that reaches the limit locks, and a right password is refused then", + %{user: user} do + assert {:error, :invalid_credentials} = fail(user.email) + assert {:error, :invalid_credentials} = fail(user.email) + assert {:error, {:locked, seconds}} = fail(user.email) + assert seconds in 1..(15 * 60) + + assert {:error, {:locked, _}} = Accounts.authenticate_by_password(user.email, @password) + assert Accounts.get_user_by_email_and_password(user.email, @password) == nil + end + + test "an address with no account locks the same way" do + email = AccountsFixtures.unique_user_email() + + assert {:error, :invalid_credentials} = fail(email) + assert {:error, :invalid_credentials} = fail(email) + assert {:error, {:locked, _}} = fail(email) + end + + test "the address is matched however it is typed", %{user: user} do + fail(user.email) + fail(" " <> String.upcase(user.email)) + assert {:error, {:locked, _}} = fail(user.email) + end + + test "a right password clears the count", %{user: user} do + fail(user.email) + fail(user.email) + assert {:ok, _} = Accounts.authenticate_by_password(user.email, @password) + assert Repo.aggregate(LoginLockout, :count) == 0 + + assert {:error, :invalid_credentials} = fail(user.email) + assert {:error, :invalid_credentials} = fail(user.email) + end + + test "failures outside the window start a new count", %{user: user} do + fail(user.email) + fail(user.email) + + old = DateTime.add(DateTime.utc_now(:second), -16, :minute) + Repo.update_all(LoginLockout, set: [window_started_at: old]) + + assert {:error, :invalid_credentials} = fail(user.email) + assert Repo.one(from(l in LoginLockout, select: l.failures)) == 1 + end + + test "a lock runs out, and the address gets every attempt back", %{user: user} do + fail(user.email) + fail(user.email) + assert {:error, {:locked, _}} = fail(user.email) + + past = DateTime.add(DateTime.utc_now(:second), -1, :second) + Repo.update_all(LoginLockout, set: [locked_until: past]) + + assert {:ok, _} = Accounts.authenticate_by_password(user.email, @password) + end + + test "0 attempts turns the lockout off", %{user: user} do + SettingsHelpers.put(:gamend_core, Accounts, :lockout_attempts, 0) + + for _ <- 1..5, do: assert({:error, :invalid_credentials} = fail(user.email)) + assert {:ok, _} = Accounts.authenticate_by_password(user.email, @password) + assert Repo.aggregate(LoginLockout, :count) == 0 + end + + test "retention prunes rows whose window and lock have run out", %{user: user} do + fail(user.email) + Gamend.Retention.prune_all() + assert Repo.aggregate(LoginLockout, :count) == 1 + + old = DateTime.add(DateTime.utc_now(:second), -20, :minute) + Repo.update_all(LoginLockout, set: [updated_at: old]) + assert %{login_lockouts: 1} = Gamend.Retention.prune_all() + assert Repo.aggregate(LoginLockout, :count) == 0 + end +end diff --git a/apps/gamend_core/test/gamend/accounts/token_validity_settings_test.exs b/apps/gamend_core/test/gamend/accounts/token_validity_settings_test.exs new file mode 100644 index 000000000..f82e9d5a7 --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/token_validity_settings_test.exs @@ -0,0 +1,52 @@ +defmodule Gamend.Accounts.TokenValiditySettingsTest do + # Settings are global Application config. + use ExUnit.Case, async: false + + alias Gamend.Accounts + alias Gamend.Accounts.UserToken + alias Gamend.SettingsHelpers + + @keys [:session_days, :magic_link_minutes, :confirm_email_days, :change_email_days] + + setup do + on_exit(fn -> for key <- @keys, do: SettingsHelpers.delete(:gamend_core, Accounts, key) end) + end + + test "the defaults are the windows the tokens always had" do + assert UserToken.session_validity_in_days() == 14 + assert UserToken.magic_link_validity_in_minutes() == 15 + assert UserToken.confirm_validity_in_days() == 7 + assert UserToken.change_email_validity_in_days() == 7 + end + + test "each window follows its setting" do + SettingsHelpers.put(:gamend_core, Accounts, :session_days, 30) + SettingsHelpers.put(:gamend_core, Accounts, :magic_link_minutes, 30) + SettingsHelpers.put(:gamend_core, Accounts, :confirm_email_days, 2) + SettingsHelpers.put(:gamend_core, Accounts, :change_email_days, 1) + + assert UserToken.session_validity_in_days() == 30 + assert UserToken.magic_link_validity_in_minutes() == 30 + assert UserToken.confirm_validity_in_days() == 2 + assert UserToken.change_email_validity_in_days() == 1 + end + + test "a magic link lasts an hour at most, and every window at least one unit" do + SettingsHelpers.put(:gamend_core, Accounts, :magic_link_minutes, 24 * 60) + assert UserToken.magic_link_validity_in_minutes() == 60 + + SettingsHelpers.put(:gamend_core, Accounts, :session_days, 0) + assert UserToken.session_validity_in_days() == 1 + end + + test "the sudo window follows its setting, and submitting gets ten minutes more" do + SettingsHelpers.put(:gamend_core, Accounts, :sudo_mode_minutes, 5) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, :sudo_mode_minutes) end) + + user = %Accounts.User{authenticated_at: DateTime.add(DateTime.utc_now(), -12, :minute)} + + assert Accounts.sudo_mode_minutes() == 5 + refute Accounts.sudo_mode?(user, -Accounts.sudo_mode_minutes()) + assert Accounts.sudo_mode?(user) + end +end diff --git a/apps/gamend_core/test/gamend/accounts/user_notifier_test.exs b/apps/gamend_core/test/gamend/accounts/user_notifier_test.exs new file mode 100644 index 000000000..fa6ca52cf --- /dev/null +++ b/apps/gamend_core/test/gamend/accounts/user_notifier_test.exs @@ -0,0 +1,61 @@ +defmodule Gamend.Accounts.UserNotifierTest do + use Gamend.DataCase, async: false + + import ExUnit.CaptureLog + + alias Gamend.Accounts.UserNotifier + alias Gamend.SettingsHelpers + + defmodule HangingAdapter do + use Swoosh.Adapter + + @impl true + def deliver(_email, _config), do: Process.sleep(:infinity) + end + + describe "a relay that never answers" do + setup do + mailer = Application.get_env(:gamend_core, Gamend.Mailer) + Application.put_env(:gamend_core, Gamend.Mailer, adapter: HangingAdapter) + SettingsHelpers.put(:gamend_core, Gamend.Mail, :send_timeout_ms, 50) + + on_exit(fn -> + Application.put_env(:gamend_core, Gamend.Mailer, mailer) + SettingsHelpers.delete(:gamend_core, Gamend.Mail, :send_timeout_ms) + end) + end + + # gen_smtp waits up to 20 minutes for each reply, and a hung relay held the + # request or job that was sending for that long. + test "is given up on after send_timeout_ms rather than waited on" do + log = + capture_log(fn -> + {micros, result} = + :timer.tc(fn -> UserNotifier.deliver_test_email("hung@example.com") end) + + assert {:error, {:error, :timeout}} = result + assert micros < 2_000_000 + end) + + assert log =~ "timeout" + end + end + + test "a send that raises is reported as a failure, not a crash" do + defmodule RaisingAdapter do + use Swoosh.Adapter + + @impl true + def deliver(_email, _config), do: raise("relay exploded") + end + + mailer = Application.get_env(:gamend_core, Gamend.Mailer) + Application.put_env(:gamend_core, Gamend.Mailer, adapter: RaisingAdapter) + on_exit(fn -> Application.put_env(:gamend_core, Gamend.Mailer, mailer) end) + + capture_log(fn -> + assert {:error, {:exception, %RuntimeError{}}} = + UserNotifier.deliver_test_email("boom@example.com") + end) + end +end diff --git a/apps/gamend_core/test/gamend/accounts_test.exs b/apps/gamend_core/test/gamend/accounts_test.exs index 20507b033..24544351a 100644 --- a/apps/gamend_core/test/gamend/accounts_test.exs +++ b/apps/gamend_core/test/gamend/accounts_test.exs @@ -1,7 +1,9 @@ defmodule Gamend.AccountsTest do use Gamend.DataCase + use Oban.Testing, repo: Gamend.Repo alias Gamend.Accounts + alias Gamend.Accounts.ConfirmationMailer import Gamend.AccountsFixtures alias Gamend.Accounts.{User, UserToken} @@ -123,7 +125,7 @@ defmodule Gamend.AccountsTest do assert linked.device_id == device_id end - test "register_user_and_deliver/3 succeeds when notifier delivers" do + test "register_user_and_deliver/3 queues the email; the job's link confirms the account" do # ensure there's already a user so we are not the first user (first user is auto-admin and skips email delivery) _existing = user_fixture() @@ -131,17 +133,29 @@ defmodule Gamend.AccountsTest do attrs = valid_user_attributes(%{"email" => email}) defmodule SuccessNotifier do - def deliver_confirmation_instructions(_user, _url), do: {:ok, :sent} + def deliver_confirmation_instructions(_user, url) do + send(self(), {:confirmation_url, url}) + {:ok, :sent} + end end {:ok, user} = Accounts.register_user_and_deliver(attrs, fn t -> "http://x/#{t}" end, SuccessNotifier) + # Committed and queued; nothing sent, no token minted, until the job runs. assert Repo.get_by(Accounts.User, id: user.id) - assert Repo.get_by(Accounts.UserToken, user_id: user.id) + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + assert [job] = all_enqueued(worker: ConfirmationMailer) + assert job.args["user_id"] == user.id + # The token is minted by the job: the queued args hold a placeholder. + assert job.args["url"] == "http://x/__gamend_confirm_token__" + + assert :ok = perform_job(ConfirmationMailer, job.args) + assert_received {:confirmation_url, "http://x/" <> token} + assert {:ok, %User{confirmed_at: %DateTime{}}} = Accounts.confirm_user_by_token(token) end - test "register_user_and_deliver/3 rolls back when notifier fails" do + test "a failed confirmation send keeps the account, for the job to retry" do _existing = user_fixture() email = unique_user_email() @@ -151,14 +165,28 @@ defmodule Gamend.AccountsTest do def deliver_confirmation_instructions(_user, _url), do: {:error, :smtp_failed} end - assert {:error, :smtp_failed} = + assert {:ok, user} = Accounts.register_user_and_deliver( attrs, fn t -> "http://x/#{t}" end, FailNotifier ) - refute Repo.get_by(Accounts.User, email: email) + assert [job] = all_enqueued(worker: ConfirmationMailer) + assert {:error, :smtp_failed} = perform_job(ConfirmationMailer, job.args) + assert Repo.get_by(Accounts.User, id: user.id) + end + + test "the first user is the admin and gets no email" do + {:ok, user} = + Accounts.register_user_and_deliver( + valid_user_attributes(), + fn t -> "http://x/#{t}" end, + Gamend.Accounts.UserNotifier + ) + + assert user.is_admin + refute_enqueued(worker: ConfirmationMailer) end end diff --git a/apps/gamend_core/test/gamend/after_commit_test.exs b/apps/gamend_core/test/gamend/after_commit_test.exs new file mode 100644 index 000000000..d73d785ae --- /dev/null +++ b/apps/gamend_core/test/gamend/after_commit_test.exs @@ -0,0 +1,165 @@ +defmodule Gamend.AfterCommitTest do + use Gamend.DataCase, async: false + + import ExUnit.CaptureLog + + alias Gamend.AfterCommit + alias Gamend.Repo + + defp mark(tag) do + test = self() + fn -> send(test, {:ran, tag}) end + end + + describe "defer/1" do + test "runs at once outside a transaction" do + assert :ok = AfterCommit.defer(mark(:now)) + assert_received {:ran, :now} + end + + test "waits for the transaction to commit" do + assert {:ok, :done} = + AfterCommit.transaction(fn -> + AfterCommit.defer(mark(:later)) + refute_received {:ran, :later} + :done + end) + + assert_received {:ran, :later} + end + + test "runs queued effects in order" do + AfterCommit.transaction(fn -> + AfterCommit.defer(mark(1)) + AfterCommit.defer(mark(2)) + end) + + assert {:messages, [{:ran, 1}, {:ran, 2}]} = Process.info(self(), :messages) + end + + test "a rollback drops them" do + assert {:error, :nope} = + AfterCommit.transaction(fn -> + AfterCommit.defer(mark(:dropped)) + Repo.rollback(:nope) + end) + + refute_received {:ran, :dropped} + refute AfterCommit.deferring?() + end + + test "an exception drops them and leaves no scope behind" do + assert_raise RuntimeError, fn -> + AfterCommit.transaction(fn -> + AfterCommit.defer(mark(:dropped)) + raise "boom" + end) + end + + refute_received {:ran, :dropped} + refute AfterCommit.deferring?() + end + + test "a nested transaction waits for the outermost commit" do + AfterCommit.transaction(fn -> + {:ok, _} = AfterCommit.transaction(fn -> AfterCommit.defer(mark(:inner)) end) + refute_received {:ran, :inner} + end) + + assert_received {:ran, :inner} + end + + test "inside a bare Repo.transaction there is no commit to wait for" do + Repo.transaction(fn -> + AfterCommit.transaction(fn -> AfterCommit.defer(mark(:bare)) end) + assert_received {:ran, :bare} + end) + end + + test "Lock.serialize is a scope, and its effects run after the lock is released" do + key = {{Gamend.Lock.Local, {"after_commit_test", "k"}}, :someone_else} + + {:ok, :done} = + Gamend.Lock.serialize("after_commit_test", "k", fn -> + AfterCommit.defer(fn -> + send(self(), {:lock_free, :global.set_lock(key, [node()], 0)}) + end) + + :done + end) + + assert_received {:lock_free, true} + :global.del_lock(key, [node()]) + end + + test "an effect that raises is logged, and the rest still run" do + log = + capture_log(fn -> + AfterCommit.transaction(fn -> + AfterCommit.defer(fn -> raise "effect failed" end) + AfterCommit.defer(mark(:after)) + end) + end) + + assert log =~ "effect failed" + assert_received {:ran, :after} + end + + test "a cache invalidation inside a transaction is repeated after the commit" do + key = {:after_commit_test, System.unique_integer([:positive])} + Gamend.Cache.put(key, :before) + + AfterCommit.transaction(fn -> + Gamend.Cache.invalidate(key) + assert Gamend.Cache.get!(key) == nil + + # A concurrent read of the not-yet-committed row caches it back. + Gamend.Cache.put(key, :stale) + end) + + assert Gamend.Cache.get!(key) == nil + end + + test "Gamend.Async.run inside a transaction starts after the commit" do + AfterCommit.transaction(fn -> + Gamend.Async.run(mark(:task)) + refute_receive {:ran, :task}, 50 + end) + + assert_receive {:ran, :task} + end + end + + describe "core" do + # A broadcast or a hook task inside a bare `Repo.transaction/2` runs while + # it holds the database (on SQLite, every request's database), and before + # the write is visible. Core opens transactions through + # `Gamend.AfterCommit` and broadcasts through `Gamend.Broadcast`, which + # wait for the commit; these are the only files allowed the raw calls. + @lib Path.expand("../../lib", __DIR__) + @raw_transaction ~r/Repo\.transact(ion)?\(/ + @raw_broadcast ~r/Phoenix\.PubSub\.broadcast\(/ + + defp offenders(pattern, allowed) do + for path <- Path.wildcard(Path.join(@lib, "**/*.ex")), + Path.relative_to(path, @lib) not in allowed, + {line, number} <- path |> File.read!() |> String.split("\n") |> Enum.with_index(1), + not String.starts_with?(String.trim_leading(line), "#"), + Regex.match?(pattern, line) do + "#{Path.relative_to(path, @lib)}:#{number}" + end + end + + test "opens no transaction but through Gamend.AfterCommit" do + assert offenders(@raw_transaction, [ + "gamend/after_commit.ex", + "gamend/lock.ex", + "gamend/repo.ex" + ]) == [] + end + + test "broadcasts through Gamend.Broadcast" do + assert offenders(@raw_broadcast, ["gamend/broadcast.ex", "gamend/cache.ex"]) == [] + end + end +end diff --git a/apps/gamend_core/test/gamend/content/markdown_test.exs b/apps/gamend_core/test/gamend/content/markdown_test.exs index 972af0c22..f16466055 100644 --- a/apps/gamend_core/test/gamend/content/markdown_test.exs +++ b/apps/gamend_core/test/gamend/content/markdown_test.exs @@ -156,6 +156,37 @@ defmodule Gamend.Content.MarkdownTest do assert html =~ ~s(href="/docs/manual/sub/thing") end + test "an index page's links resolve against its own folder, not its parent" do + html = + render!( + "[a](builds.md) [b](signing/10-macos.md#notary) [c](../principles.md)", + base_path: "/docs", + slug: "forge", + index: true + ) + + assert html =~ ~s(href="/docs/forge/builds") + assert html =~ ~s(href="/docs/forge/signing/macos#notary") + assert html =~ ~s(href="/docs/principles") + end + + test "render_file/2 knows an index.md from its name" do + dir = Path.join(System.tmp_dir!(), "gs-md-index-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + + index = Path.join(dir, "index.md") + page = Path.join(dir, "20-page.md") + File.write!(index, "[a](builds.md)") + File.write!(page, "[a](builds.md)") + + assert Markdown.render_file(index, base_path: "/docs", slug: "forge") =~ + ~s(href="/docs/forge/builds") + + assert Markdown.render_file(page, base_path: "/docs", slug: "forge/page") =~ + ~s(href="/docs/forge/builds") + end + test "absolute and external links are left alone, as is everything without a base path" do html = render!("[a](/docs/x) [b](https://e.com/a.md) [c](./y.md)", base_path: "/docs") diff --git a/apps/gamend_core/test/gamend/hooks_host_modules_test.exs b/apps/gamend_core/test/gamend/hooks_host_modules_test.exs new file mode 100644 index 000000000..8de63803f --- /dev/null +++ b/apps/gamend_core/test/gamend/hooks_host_modules_test.exs @@ -0,0 +1,45 @@ +defmodule Gamend.Hooks.HostModulesTest.Deletions do + def after_user_deleted(user) do + send(:persistent_term.get({__MODULE__, :test_pid}), {:host_saw, user.id}) + :ok + end +end + +defmodule Gamend.Hooks.HostModulesTest do + use ExUnit.Case, async: false + + alias Gamend.Hooks + alias Gamend.Hooks.HostModulesTest.Deletions + + setup do + original = Application.get_env(:gamend_core, :host_hook_modules) + :persistent_term.put({Deletions, :test_pid}, self()) + + on_exit(fn -> + :persistent_term.erase({Deletions, :test_pid}) + + if original, + do: Application.put_env(:gamend_core, :host_hook_modules, original), + else: Application.delete_env(:gamend_core, :host_hook_modules) + end) + end + + test "a host module hears the event it implements, beside the hooks module" do + Application.put_env(:gamend_core, :host_hook_modules, [Deletions]) + + Hooks.internal_call(:after_user_deleted, [%{id: "u1"}]) + + assert_receive {:host_saw, "u1"}, 1_000 + # The hooks module stays the one `Gamend.Hooks.call/3` reaches. + assert Hooks.module() != Deletions + end + + test "is not called for what it does not implement, and nothing changes unset" do + Application.put_env(:gamend_core, :host_hook_modules, [Deletions]) + assert {:ok, %{"name" => "x"}} = Hooks.internal_call(:before_lobby_create, [%{"name" => "x"}]) + + Application.delete_env(:gamend_core, :host_hook_modules) + Hooks.internal_call(:after_user_deleted, [%{id: "u2"}]) + refute_receive {:host_saw, "u2"}, 200 + end +end diff --git a/apps/gamend_core/test/gamend/http_test.exs b/apps/gamend_core/test/gamend/http_test.exs new file mode 100644 index 000000000..ffed13e2b --- /dev/null +++ b/apps/gamend_core/test/gamend/http_test.exs @@ -0,0 +1,37 @@ +defmodule Gamend.HTTPTest do + use ExUnit.Case, async: false + + alias Gamend.SettingsHelpers + + test "every call gets the declared timeout and retries" do + opts = Gamend.HTTP.options() + + assert opts[:receive_timeout] == 10_000 + assert opts[:connect_options] == [timeout: 10_000] + assert opts[:max_retries] == 1 + end + + test "the settings drive them, and a caller's own options win" do + SettingsHelpers.put(:gamend_core, Gamend.HTTP, :client_timeout_ms, 2_000) + SettingsHelpers.put(:gamend_core, Gamend.HTTP, :client_retries, 0) + + on_exit(fn -> + SettingsHelpers.delete(:gamend_core, Gamend.HTTP, :client_timeout_ms) + SettingsHelpers.delete(:gamend_core, Gamend.HTTP, :client_retries) + end) + + opts = Gamend.HTTP.options(receive_timeout: 500, form: [a: 1]) + + assert opts[:receive_timeout] == 500 + assert opts[:connect_options] == [timeout: 2_000] + assert opts[:max_retries] == 0 + assert opts[:form] == [a: 1] + end + + test "a request goes out with them" do + Req.Test.stub(Gamend.HTTPTest, fn conn -> Req.Test.json(conn, %{"ok" => true}) end) + + assert {:ok, %Req.Response{status: 200, body: %{"ok" => true}}} = + Gamend.HTTP.get("http://provider.test/x", plug: {Req.Test, Gamend.HTTPTest}) + end +end diff --git a/apps/gamend_core/test/gamend/jobs_oban_config_test.exs b/apps/gamend_core/test/gamend/jobs_oban_config_test.exs index 95e0d864a..243a19b23 100644 --- a/apps/gamend_core/test/gamend/jobs_oban_config_test.exs +++ b/apps/gamend_core/test/gamend/jobs_oban_config_test.exs @@ -43,6 +43,28 @@ defmodule Gamend.JobsObanConfigTest do end) end + test "queue sizes and the pruning window follow their settings" do + Gamend.SettingsHelpers.put(:gamend_core, Jobs, :queue_hooks, 40) + Gamend.SettingsHelpers.put(:gamend_core, Jobs, :prune_after_days, 2) + + on_exit(fn -> + Gamend.SettingsHelpers.delete(:gamend_core, Jobs, :queue_hooks) + Gamend.SettingsHelpers.delete(:gamend_core, Jobs, :prune_after_days) + end) + + with_adapter(Ecto.Adapters.Postgres, fn -> + config = Jobs.oban_config() + + assert config[:queues][:hooks] == 40 + assert config[:queues][:push] == Gamend.Settings.get(Gamend.Push, :queue_concurrency) + + assert {Oban.Plugins.Pruner, pruner} = + Enum.find(config[:plugins], &match?({Oban.Plugins.Pruner, _}, &1)) + + assert pruner[:max_age] == 2 * 86_400 + end) + end + test "postgres keeps the configured concurrency untouched" do with_adapter(Ecto.Adapters.Postgres, fn -> config = Jobs.oban_config() diff --git a/apps/gamend_core/test/gamend/lock_hold_test.exs b/apps/gamend_core/test/gamend/lock_hold_test.exs new file mode 100644 index 000000000..82338e629 --- /dev/null +++ b/apps/gamend_core/test/gamend/lock_hold_test.exs @@ -0,0 +1,172 @@ +defmodule Gamend.LockHoldTest do + @moduledoc """ + A plugin's `before_*` hook runs outside the lock of the write it gates. + + Inside it, the hook held the lock and, on SQLite, the database's only + connection for up to its timeout: repeated wrong lobby passwords from one + player (a bcrypt check each) stalled every request in the server. Each hook + here probes the lock its caller would take, without waiting: it is free + only if the caller has not taken it yet. + """ + use Gamend.DataCase, async: false + + alias Gamend.Accounts.User + alias Gamend.AccountsFixtures + alias Gamend.{Groups, KV, Lobbies, Repo} + alias Gamend.Hooks.Default + alias Gamend.Payments.Entitlement + + defmodule ProbeHooks do + use Gamend.TestSupport.NoopHooks + + def probe(namespace, id) do + key = {{Gamend.Lock.Local, {namespace, id}}, make_ref()} + free = :global.set_lock(key, [node()], 0) + if free, do: :global.del_lock(key, [node()]) + send(Application.get_env(:gamend_core, :lock_probe_pid), {:lock_free, namespace, free}) + end + + @impl true + def before_lobby_join(user, lobby, opts) do + probe(:lobby, lobby.id) + {:ok, {user, lobby, opts}} + end + + @impl true + def before_group_join(user, group, opts) do + probe(:group, group.id) + {:ok, {user, group, opts}} + end + + @impl true + def before_lobby_update(lobby, attrs) do + probe(:lobby, lobby.id) + {:ok, attrs} + end + + @impl true + def before_user_update(user, attrs) do + probe("user_payment_metadata", user.id) + {:ok, attrs} + end + end + + # Writes a key between the unlocked read and the locked write, as a + # concurrent merge would, the first time it is asked. + defmodule RacingMergeHooks do + use Gamend.TestSupport.NoopHooks + + @impl true + def before_lobby_update(lobby, attrs) do + if :persistent_term.get({__MODULE__, :raced}, false) == false do + :persistent_term.put({__MODULE__, :raced}, true) + + Gamend.Repo.update_all( + from(l in Gamend.Lobbies.Lobby, where: l.id == ^lobby.id), + set: [metadata: Map.put(lobby.metadata || %{}, "theirs", 1)] + ) + end + + {:ok, attrs} + end + end + + setup do + hooks = Application.get_env(:gamend_core, :hooks_module) + Application.put_env(:gamend_core, :hooks_module, ProbeHooks) + Application.put_env(:gamend_core, :lock_probe_pid, self()) + + on_exit(fn -> + Application.put_env(:gamend_core, :hooks_module, hooks) + Application.delete_env(:gamend_core, :lock_probe_pid) + end) + + host = AccountsFixtures.user_fixture() + %{host: host, player: AccountsFixtures.user_fixture()} + end + + test "lobby join: the hook and the password check run before the lock", %{ + host: host, + player: player + } do + {:ok, lobby} = + Lobbies.create_lobby(%{title: "locked-room", host_id: host.id, password: "secret"}) + + # Argon2id, as account passwords: ~24ms a check rather than bcrypt's ~250ms. + assert "$argon2id$" <> _ = lobby.password_hash + + assert {:error, :invalid_password} = Lobbies.join_lobby(player, lobby, %{password: "wrong"}) + assert_received {:lock_free, :lobby, true} + + assert {:ok, _} = Lobbies.join_lobby(player, lobby, %{password: "secret"}) + assert_received {:lock_free, :lobby, true} + end + + test "lobby join: a full lobby is refused without asking the hook", %{ + host: host, + player: player + } do + # The host is seated by creating it. + {:ok, lobby} = Lobbies.create_lobby(%{title: "tiny", host_id: host.id, max_users: 1}) + + assert {:error, :full} = Lobbies.join_lobby(player, lobby) + refute_received {:lock_free, :lobby, _} + end + + test "group join: the hook runs before the lock", %{host: host, player: player} do + {:ok, group} = Groups.create_group(host.id, %{title: "open-group", type: "public"}) + + assert {:ok, _member} = Groups.join_group(player.id, group.id) + assert_received {:lock_free, :group, true} + end + + test "lobby metadata merge: the hook runs before the lock", %{host: host} do + {:ok, lobby} = Lobbies.create_lobby(%{title: "meta", host_id: host.id}) + + assert {:ok, merged} = Lobbies.merge_metadata(lobby, %{"mine" => 1}) + assert merged.metadata["mine"] == 1 + assert_received {:lock_free, :lobby, true} + end + + test "lobby metadata merge: a concurrent write starts it over, and both keys survive", %{ + host: host + } do + Application.put_env(:gamend_core, :hooks_module, RacingMergeHooks) + on_exit(fn -> :persistent_term.erase({RacingMergeHooks, :raced}) end) + {:ok, lobby} = Lobbies.create_lobby(%{title: "raced", host_id: host.id}) + + assert {:ok, merged} = Lobbies.merge_metadata(lobby, %{"mine" => 1}) + assert merged.metadata == %{"mine" => 1, "theirs" => 1} + end + + test "payment metadata: the hook runs before the lock", %{player: player} do + entitlement = %Entitlement{ + id: Ecto.UUID.generate(), + user_id: player.id, + key: "vip", + status: "active" + } + + assert :ok = Default.after_entitlement_changed(entitlement) + assert_received {:lock_free, "user_payment_metadata", true} + + metadata = Repo.get!(User, player.id).metadata + assert get_in(metadata, ["payments", "entitlements", "vip"]) == true + end + + test "lobby delete clears its KV in one statement", %{host: host} do + {:ok, lobby} = Lobbies.create_lobby(%{title: "kv-room", host_id: host.id}) + + for key <- ~w(a b c), do: {:ok, _} = KV.put(key, %{"v" => key}, %{}, lobby_id: lobby.id) + + assert {:ok, _} = Lobbies.delete_lobby(lobby) + assert KV.delete_lobby_entries(lobby.id) == 0 + assert :error = KV.get("a", lobby_id: lobby.id) + end + + test "Lock.exclusive holds no transaction while its function runs" do + # Each write inside commits by itself: the tournament tick used to hold one + # transaction, and every row it touched, across all due tournaments. + assert {:ok, false} = Gamend.Lock.exclusive("lock_hold_test", "tick", &Repo.in_transaction?/0) + end +end diff --git a/apps/gamend_core/test/gamend/matchmaking_test.exs b/apps/gamend_core/test/gamend/matchmaking_test.exs index 98b081bf2..8ba9b2bab 100644 --- a/apps/gamend_core/test/gamend/matchmaking_test.exs +++ b/apps/gamend_core/test/gamend/matchmaking_test.exs @@ -42,6 +42,17 @@ defmodule Gamend.MatchmakingTest do assert ticket.timeout_ms == Gamend.Limits.get(:matchmaking_timeout_ms) end + test "a ticket that leaves its size out gets the server's defaults" do + for {key, value} <- [matchmaking_default_min_players: 4, matchmaking_default_max_players: 8] do + Gamend.SettingsHelpers.put(:gamend_core, Gamend.Limits, key, value) + on_exit(fn -> Gamend.SettingsHelpers.delete(:gamend_core, Gamend.Limits, key) end) + end + + {:ok, ticket} = Matchmaking.join(user(), %{mode: "squads"}) + + assert {ticket.min_players, ticket.max_players} == {4, 8} + end + test "rejects max_players below min_players" do assert {:error, changeset} = Matchmaking.join(user(), %{}, 4, 2) assert %{max_players: _} = errors_on(changeset) diff --git a/apps/gamend_core/test/gamend/payments/provider_adapters_test.exs b/apps/gamend_core/test/gamend/payments/provider_adapters_test.exs index 7ba1dad7e..14a5e6009 100644 --- a/apps/gamend_core/test/gamend/payments/provider_adapters_test.exs +++ b/apps/gamend_core/test/gamend/payments/provider_adapters_test.exs @@ -55,6 +55,11 @@ defmodule Gamend.Payments.ProviderAdaptersTest do }} end + def create_billing_portal_session(params, opts) do + send(self(), {:stripe_create_billing_portal_session, params, opts}) + {:ok, %{id: "bps_test", url: "https://billing.stripe.test/session"}} + end + def construct_webhook_event(raw_body, signature_header, secret, tolerance_seconds) do send( self(), @@ -305,6 +310,117 @@ defmodule Gamend.Payments.ProviderAdaptersTest do assert opts[:idempotency_key] == "order_42" end + test "Stripe checkout: a one-off payment creates a customer, a known one is reused" do + Application.put_env(:gamend_core, :stripe_client, StripeClient) + put_setting(:environment, :sandbox) + put_setting(:stripe_sandbox_secret_key, "sk_test_sdk_123") + + urls = %{ + "success_url" => "https://example.test/success", + "cancel_url" => "https://example.test/cancel" + } + + one_off = %Product{id: 10, sku: "pro_lifetime", title: "Pro", kind: "entitlement"} + sub = %Product{id: 11, sku: "pro_yearly", title: "Pro", kind: "subscription"} + purchase = %Purchase{id: 42, user_id: 7, order_id: "order_42", quantity: 1} + + assert {:ok, _} = + Stripe.create_checkout_session( + purchase, + %ProviderProduct{external_id: "price_1", product: one_off}, + urls + ) + + assert_received {:stripe_create_checkout_session, params, _opts} + assert params.customer_creation == "always" + refute Map.has_key?(params, :customer) + + assert {:ok, _} = + Stripe.create_checkout_session( + purchase, + %ProviderProduct{external_id: "price_2", product: sub}, + urls + ) + + # Subscription mode always creates one; Stripe rejects customer_creation there. + assert_received {:stripe_create_checkout_session, params, _opts} + refute Map.has_key?(params, :customer_creation) + + assert {:ok, _} = + Stripe.create_checkout_session( + purchase, + %ProviderProduct{external_id: "price_1", product: one_off}, + Map.put(urls, "stripe_customer_id", "cus_known") + ) + + assert_received {:stripe_create_checkout_session, params, _opts} + assert params.customer == "cus_known" + refute Map.has_key?(params, :customer_creation) + + # Anything that is not a customer id is ignored, never forwarded. + assert {:ok, _} = + Stripe.create_checkout_session( + purchase, + %ProviderProduct{external_id: "price_1", product: one_off}, + Map.put(urls, "stripe_customer_id", "acct_other") + ) + + assert_received {:stripe_create_checkout_session, params, _opts} + refute Map.has_key?(params, :customer) + end + + test "Stripe Managed Payments: the flag and a new enough API version on checkout only" do + Application.put_env(:gamend_core, :stripe_client, StripeClient) + put_setting(:environment, :sandbox) + put_setting(:stripe_sandbox_secret_key, "sk_test_sdk_123") + + product = %Product{id: 10, sku: "pro_yearly", title: "Pro", kind: "subscription"} + provider_product = %ProviderProduct{external_id: "price_1", product: product} + purchase = %Purchase{id: 42, user_id: 7, order_id: "order_42", quantity: 1} + + urls = %{ + "success_url" => "https://example.test/success", + "cancel_url" => "https://example.test/cancel" + } + + # Off: nothing changes. + assert {:ok, _} = Stripe.create_checkout_session(purchase, provider_product, urls) + assert_received {:stripe_create_checkout_session, params, opts} + refute Map.has_key?(params, :managed_payments) + assert opts[:api_version] == "2022-11-15" + + # On: the flag, and the checkout call raised to basil. + put_setting(:stripe_managed_payments, true) + assert {:ok, _} = Stripe.create_checkout_session(purchase, provider_product, urls) + assert_received {:stripe_create_checkout_session, params, opts} + assert params.managed_payments == %{enabled: true} + refute Map.has_key?(params, :automatic_tax) + refute Map.has_key?(params, :payment_method_types) + assert opts[:api_version] == "2025-03-31.basil" + # Every other call keeps the configured version. + assert ProviderConfig.stripe_api_version() == "2022-11-15" + + # A configured version newer than the minimum is kept, not lowered. + put_setting(:stripe_api_version, "2026-01-28.clover") + assert {:ok, _} = Stripe.create_checkout_session(purchase, provider_product, urls) + assert_received {:stripe_create_checkout_session, _params, opts} + assert opts[:api_version] == "2026-01-28.clover" + end + + test "Stripe creates a billing portal session through the SDK client" do + Application.put_env(:gamend_core, :stripe_client, StripeClient) + put_setting(:environment, :sandbox) + put_setting(:stripe_sandbox_secret_key, "sk_test_sdk_123") + + assert {:ok, session} = + Stripe.create_billing_portal_session("cus_123", "https://example.test/back") + + assert session["url"] == "https://billing.stripe.test/session" + assert_received {:stripe_create_billing_portal_session, params, opts} + assert params == %{customer: "cus_123", return_url: "https://example.test/back"} + assert opts[:api_key] == "sk_test_sdk_123" + end + test "Stripe retrieves checkout session through SDK client with pinned API options" do Application.put_env(:gamend_core, :stripe_client, StripeClient) put_setting(:environment, :sandbox) diff --git a/apps/gamend_core/test/gamend/retention_test.exs b/apps/gamend_core/test/gamend/retention_test.exs index f1431fbe1..13da8fe40 100644 --- a/apps/gamend_core/test/gamend/retention_test.exs +++ b/apps/gamend_core/test/gamend/retention_test.exs @@ -287,6 +287,24 @@ defmodule Gamend.RetentionTest do assert Repo.get(Lobby, fresh.id) end + test "the live cycle reaps it too, and runs nothing else" do + Application.put_env(:gamend_core, Gamend.Retention, abandoned_lobby_minutes: 15) + + quiet = age_lobby(lobby_fixture(), 20) + results = Retention.prune_live() + + assert results |> Map.keys() |> Enum.sort() == + [ + :abandoned_parties, + :lobbies, + :offline_lobby_memberships, + :offline_party_memberships + ] + + assert results.lobbies == 1 + refute Repo.get(Lobby, quiet.id) + end + test "keeps a lobby inside the window" do Application.put_env(:gamend_core, Gamend.Retention, abandoned_lobby_minutes: 60) diff --git a/apps/gamend_core/test/gamend/runtime_settings_test.exs b/apps/gamend_core/test/gamend/runtime_settings_test.exs new file mode 100644 index 000000000..3a4340796 --- /dev/null +++ b/apps/gamend_core/test/gamend/runtime_settings_test.exs @@ -0,0 +1,81 @@ +defmodule Gamend.RuntimeSettingsTest do + @moduledoc """ + Values that used to be literals and are now declared settings: each keeps + its old value by default and follows its setting. + """ + # Settings are global Application config. + use ExUnit.Case, async: false + + alias Ecto.Adapters.SQL.Sandbox + alias Gamend.Accounts.StalePresenceSweeper + alias Gamend.Hooks.PluginManager + alias Gamend.SettingsHelpers + alias Gamend.Storage + + defp put(module, key, value) do + SettingsHelpers.put(:gamend_core, module, key, value) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, module, key) end) + end + + describe "storage" do + test "a private S3 bucket hands out a URL that does not expire" do + put(Storage, :adapter, :s3) + + assert Storage.url("avatars/u/a.png") == "/storage/avatars/u/a.png" + end + + test "with a public_url, S3 URLs point there, signed or not" do + put(Storage, :adapter, :s3) + put(Storage, :public_url, "https://cdn.example.com/") + + assert Storage.url("avatars/u/a.png") == "https://cdn.example.com/avatars/u/a.png" + + assert Storage.url("avatars/u/a.png", signed: true) == + "https://cdn.example.com/avatars/u/a.png" + end + + test "an upload ticket lasts upload_ttl_seconds, on the ticket and on its token" do + assert Storage.upload_ttl_seconds() == 600 + put(Storage, :upload_ttl_seconds, 1_800) + + assert {:ok, %{expires_in: 1_800}} = Storage.presigned_upload("avatars/u/a.png") + end + + test "a signed link lasts at most S3's seven days" do + assert Storage.signed_url_seconds() == 3_600 + put(Storage, :signed_url_seconds, 30 * 86_400) + assert Storage.signed_url_seconds() == 7 * 86_400 + end + end + + test "the cache TTL follows its setting" do + assert Gamend.Cache.ttl() == 60_000 + put(Gamend.Cache.Settings, :ttl_ms, 5_000) + assert Gamend.Cache.ttl() == 5_000 + end + + describe "hooks" do + test "a hook call gets the shorter budget inside a transaction" do + put(PluginManager, :call_timeout_ms, 30_000) + put(PluginManager, :call_timeout_in_transaction_ms, 2_000) + + assert PluginManager.call_timeout_ms() == 30_000 + + Sandbox.checkout(Gamend.Repo) + + assert {:ok, 2_000} = Gamend.Repo.transaction(fn -> PluginManager.call_timeout_ms() end) + end + end + + describe "presence" do + test "the heartbeat is three fifths of the stale threshold, and at most 3 minutes" do + assert StalePresenceSweeper.heartbeat_ms() == 180_000 + + put(StalePresenceSweeper, :stale_threshold_s, 60) + assert StalePresenceSweeper.heartbeat_ms() == 36_000 + + put(StalePresenceSweeper, :stale_threshold_s, 3_600) + assert StalePresenceSweeper.heartbeat_ms() == 180_000 + end + end +end diff --git a/apps/gamend_core/test/gamend/stable_storage_urls_migration_test.exs b/apps/gamend_core/test/gamend/stable_storage_urls_migration_test.exs new file mode 100644 index 000000000..e107dd8c4 --- /dev/null +++ b/apps/gamend_core/test/gamend/stable_storage_urls_migration_test.exs @@ -0,0 +1,43 @@ +defmodule Gamend.StableStorageUrlsMigrationTest do + use Gamend.DataCase, async: false + + alias Gamend.Accounts.User + alias Gamend.AccountsFixtures + + @migration Gamend.Repo.Migrations.StableStorageUrls + + # The test run migrated the database already, so the module is usually in + # memory; requiring the file again would redefine it. + setup_all do + unless Code.ensure_loaded?(@migration) do + Code.require_file("priv/repo/migrations/20260925130200_stable_storage_urls.exs") + end + + :ok + end + + test "signed S3 links become /storage/, and nothing else changes" do + signed = AccountsFixtures.user_fixture() + external = AccountsFixtures.user_fixture() + + key = "avatars/#{signed.id}/abc123.png" + + Repo.update_all(from(u in User, where: u.id == ^signed.id), + set: [ + profile_url: + "https://bucket.s3.amazonaws.com/#{key}?X-Amz-Algorithm=AWS4&X-Amz-Signature=deadbeef" + ] + ) + + Repo.update_all(from(u in User, where: u.id == ^external.id), + set: [profile_url: "https://cdn.discordapp.com/avatars/1/2.png"] + ) + + Ecto.Migrator.up(Repo, 99_999_999_999_990, @migration, log: false) + + assert Repo.get!(User, signed.id).profile_url == "/storage/" <> key + + assert Repo.get!(User, external.id).profile_url == + "https://cdn.discordapp.com/avatars/1/2.png" + end +end diff --git a/apps/gamend_web/lib/gamend_web/api_spec.ex b/apps/gamend_web/lib/gamend_web/api_spec.ex index a87b58c76..ad6567161 100644 --- a/apps/gamend_web/lib/gamend_web/api_spec.ex +++ b/apps/gamend_web/lib/gamend_web/api_spec.ex @@ -35,8 +35,8 @@ defmodule GamendWeb.ApiSpec do - **Steam (OpenID)**: Use `/api/v1/auth/steam` flow Both methods return: - - `access_token` - Short-lived (15 min), use for API requests - - `refresh_token` - Long-lived (30 days), use to get new access tokens + - `access_token` - Short-lived (15 min by default), use for API requests; `expires_in` gives its lifetime in seconds + - `refresh_token` - Long-lived (30 days by default), use to get new access tokens ### **1.2 Using Tokens** Include the access token in the Authorization header: diff --git a/apps/gamend_web/lib/gamend_web/auth/api_token_auth.ex b/apps/gamend_web/lib/gamend_web/auth/api_token_auth.ex new file mode 100644 index 000000000..0ea351648 --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/auth/api_token_auth.ex @@ -0,0 +1,70 @@ +defmodule GamendWeb.Auth.ApiTokenAuth do + @moduledoc """ + Accepts a personal API token (`Gamend.Accounts.ApiTokens`) wherever an + access token is accepted. + + First in both API pipelines. A `Bearer gamend_pat_…` header is verified here + and handed to Guardian as the current token and the claims an access token + for the same user would carry — `sub`, `tv`, `typ: "access"`. Guardian's own + plugs then run unchanged: `VerifyHeader` sees a token already in place and + skips, `EnsureAuthenticated` passes, and `LoadResource` resolves the user + through `GamendWeb.Auth.Guardian.resource_from_claims/1`, which is where a + deactivated account is refused for either kind of token. + + Any other header — a JWT, none at all — passes through untouched. A + personal token that does not verify is a 401 here, with the same body a bad + JWT gets: falling through would only reach `VerifyHeader`, which would fail + it anyway with a less useful reason. + """ + + @behaviour Plug + + alias Gamend.Accounts.ApiTokens + alias GamendWeb.Auth.ErrorHandler + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, opts) do + case bearer(conn) do + token when is_binary(token) -> + if ApiTokens.token?(token), do: authenticate(conn, token, opts), else: conn + + nil -> + conn + end + end + + defp authenticate(conn, token, opts) do + case ApiTokens.verify(token) do + {:ok, user, row} -> + ApiTokens.touch(row) + + claims = %{ + "sub" => to_string(user.id), + "tv" => user.token_version, + "typ" => "access", + "pat" => row.id + } + + conn + |> Guardian.Plug.put_current_token(token) + |> Guardian.Plug.put_current_claims(claims) + + :error -> + conn + |> ErrorHandler.auth_error({:invalid_token, :api_token}, opts) + |> Plug.Conn.halt() + end + end + + defp bearer(conn) do + Enum.find_value(Plug.Conn.get_req_header(conn, "authorization"), fn header -> + case Regex.run(~r/\ABearer\s+(\S+)\z/i, String.trim(header)) do + [_, token] -> token + _ -> nil + end + end) + end +end diff --git a/apps/gamend_web/lib/gamend_web/auth/guardian.ex b/apps/gamend_web/lib/gamend_web/auth/guardian.ex index d317251dd..6af4dfb9c 100644 --- a/apps/gamend_web/lib/gamend_web/auth/guardian.ex +++ b/apps/gamend_web/lib/gamend_web/auth/guardian.ex @@ -6,7 +6,10 @@ defmodule GamendWeb.Auth.Guardian do It works alongside the existing session-based authentication for browser flows. """ - use Guardian, otp_app: :gamend_web + # Token lifetimes follow the `auth.*_token_ttl_*` settings, in every + # environment. An option here wins over the app config, so a host cannot + # shadow the settings with a literal `ttl` or `token_ttl`. + use Guardian, otp_app: :gamend_web, token_ttl: {GamendWeb.Auth.Tokens, :ttls, []} alias Gamend.Accounts @@ -49,7 +52,7 @@ defmodule GamendWeb.Auth.Guardian do # Deactivating an account has to end its API access too, not only # block new logins — otherwise an admin who un-approves someone - # leaves them refreshing tokens for the full 30-day refresh TTL. + # leaves them refreshing tokens for the whole refresh TTL. not Accounts.user_activated?(user) -> {:error, :account_deactivated} diff --git a/apps/gamend_web/lib/gamend_web/auth/optional_pipeline.ex b/apps/gamend_web/lib/gamend_web/auth/optional_pipeline.ex index 5aaab7b67..f8f3efc07 100644 --- a/apps/gamend_web/lib/gamend_web/auth/optional_pipeline.ex +++ b/apps/gamend_web/lib/gamend_web/auth/optional_pipeline.ex @@ -16,6 +16,9 @@ defmodule GamendWeb.Auth.OptionalPipeline do module: GamendWeb.Auth.Guardian, error_handler: GamendWeb.Auth.ErrorHandler + # A personal API token counts here as it does in `GamendWeb.Auth.Pipeline`. + plug GamendWeb.Auth.ApiTokenAuth + # Same token-type pin as `GamendWeb.Auth.Pipeline` — a refresh token must not # authenticate a request just because this pipeline tolerates no token at all. plug Guardian.Plug.VerifyHeader, diff --git a/apps/gamend_web/lib/gamend_web/auth/pipeline.ex b/apps/gamend_web/lib/gamend_web/auth/pipeline.ex index 5d4ed0c8b..3fdafc564 100644 --- a/apps/gamend_web/lib/gamend_web/auth/pipeline.ex +++ b/apps/gamend_web/lib/gamend_web/auth/pipeline.ex @@ -11,6 +11,10 @@ defmodule GamendWeb.Auth.Pipeline do module: GamendWeb.Auth.Guardian, error_handler: GamendWeb.Auth.ErrorHandler + # A personal API token (`gamend_pat_…`) is verified first and put in place + # as if it were an access token; everything below then runs on it as is. + plug GamendWeb.Auth.ApiTokenAuth + # `claims:` pins the token type. Without it Guardian verifies only the # signature, so a 30-day refresh token authenticated every API route as if it # were a 15-minute access token — which defeats the point of the short access diff --git a/apps/gamend_web/lib/gamend_web/auth/tokens.ex b/apps/gamend_web/lib/gamend_web/auth/tokens.ex index 408e33840..146bb11bc 100644 --- a/apps/gamend_web/lib/gamend_web/auth/tokens.ex +++ b/apps/gamend_web/lib/gamend_web/auth/tokens.ex @@ -5,11 +5,10 @@ defmodule GamendWeb.Auth.Tokens do sign-in carries the same `Session` and the same side effects. """ + alias Gamend.Accounts alias Gamend.Accounts.User alias GamendWeb.Auth.Guardian - @access_ttl_seconds 900 - @doc """ A new access and refresh token for `user`, as `GamendWeb.Schemas.Session` describes them, after the login side effects: `last_seen_at`, the @@ -35,23 +34,69 @@ defmodule GamendWeb.Auth.Tokens do end) {:ok, access_token, _} = Guardian.encode_and_sign(user, %{}, token_type: "access") - - {:ok, refresh_token, _} = - Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {30, :days}) + {:ok, refresh_token, _} = Guardian.encode_and_sign(user, %{}, token_type: "refresh") session(user, access_token, refresh_token) end + @doc """ + Why `user` may not sign in over the API, as `{status, code, message}` for + `GamendWeb.Reply.reply_error/4`, or nil when they may. Every API sign-in + asks before `sign_in/1`. + + An account scheduled for deletion is refused rather than restored: a game + client signs in on its own (a stored device id, a silent provider login), and + that must not undo a deletion its owner asked for. Signing in on the website + is the way back (`GamendWeb.UserAuth.log_in_user/3`). + """ + @spec refusal(User.t()) :: {atom(), String.t(), String.t()} | nil + def refusal(%User{} = user) do + cond do + not Accounts.user_activated?(user) -> + {:forbidden, "account_not_activated", + "Your account is pending activation by an administrator."} + + Accounts.deletion_scheduled?(user) -> + {:forbidden, "deletion_scheduled", + "This account is scheduled for deletion. Sign in on the website to keep it."} + + true -> + nil + end + end + @doc "The `Session` fields for tokens already issued (a refresh keeps its refresh token)." @spec session(User.t(), String.t(), String.t()) :: map() def session(%User{} = user, access_token, refresh_token) do %{ access_token: access_token, refresh_token: refresh_token, - expires_in: @access_ttl_seconds, + expires_in: access_ttl_seconds(), user_id: user.id, username: user.username || "", display_name: user.display_name || "" } end + + @doc """ + How long each token type lives, from `auth.access_token_ttl_minutes` and + `auth.refresh_token_ttl_days`. `GamendWeb.Auth.Guardian` takes it as its + `token_ttl`, so every token signed anywhere carries the same lifetime for + its type. + """ + @spec ttls() :: %{String.t() => {pos_integer(), :minutes | :days}} + def ttls do + %{"access" => {access_ttl_minutes(), :minutes}, "refresh" => {refresh_ttl_days(), :days}} + end + + @doc "Seconds an access token issued now stays valid: the `expires_in` a sign-in answers." + @spec access_ttl_seconds() :: pos_integer() + def access_ttl_seconds, do: access_ttl_minutes() * 60 + + # Floored at 1: zero or less would sign tokens that are expired on arrival, + # locking every client out with nothing in the response saying why. + defp access_ttl_minutes, + do: max(Gamend.Settings.get(Gamend.Accounts, :access_token_ttl_minutes), 1) + + defp refresh_ttl_days, do: max(Gamend.Settings.get(Gamend.Accounts, :refresh_token_ttl_days), 1) end diff --git a/apps/gamend_web/lib/gamend_web/channels/signaling_channel.ex b/apps/gamend_web/lib/gamend_web/channels/signaling_channel.ex index 807b5b155..8a101da0e 100644 --- a/apps/gamend_web/lib/gamend_web/channels/signaling_channel.ex +++ b/apps/gamend_web/lib/gamend_web/channels/signaling_channel.ex @@ -51,14 +51,9 @@ defmodule GamendWeb.SignalingChannel do alias Gamend.Signaling alias GamendWeb.ChannelEvents - # WebSocket message rate limits (per user) — defaults, overridden by config - @default_ws_rate_limit 300 - @default_ws_rate_window :timer.seconds(10) - - # Separate ICE candidate budget — prevents ICE flooding from starving - # other channel events. A typical WebRTC session sends 5–30 candidates. - @default_ice_rate_limit 150 - @default_ice_rate_window :timer.seconds(30) + # Per-user message and ICE budgets are the `signaling_*` settings on + # GamendWeb.Plugs.RateLimiter. ICE has its own so a flood of candidates cannot + # starve other channel events; a typical WebRTC session sends 5–30. @impl true def join("signaling:" <> room_id, _payload, socket) do @@ -427,12 +422,10 @@ defmodule GamendWeb.SignalingChannel do # ── WebSocket rate limiting ───────────────────────────────────────────── defp check_ws_rate_limit(socket) do - config = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) - - if Keyword.get(config, :enabled, true) do + if rate_setting(:enabled) do user_id = socket.assigns.current_scope.user_id - limit = Keyword.get(config, :signaling_ws_limit, @default_ws_rate_limit) - window = Keyword.get(config, :signaling_ws_window, @default_ws_rate_window) + limit = rate_setting(:signaling_ws_limit) + window = rate_setting(:signaling_ws_window_ms) case GamendWeb.RateLimit.hit("signaling_ws:#{user_id}", window, limit) do {:allow, _count} -> @@ -451,12 +444,10 @@ defmodule GamendWeb.SignalingChannel do end defp check_ice_rate_limit(socket) do - config = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) - - if Keyword.get(config, :enabled, true) do + if rate_setting(:enabled) do user_id = socket.assigns.current_scope.user_id - limit = Keyword.get(config, :signaling_ice_limit, @default_ice_rate_limit) - window = Keyword.get(config, :signaling_ice_window, @default_ice_rate_window) + limit = rate_setting(:signaling_ice_limit) + window = rate_setting(:signaling_ice_window_ms) case GamendWeb.RateLimit.hit("signaling_ice:#{user_id}", window, limit) do {:allow, _count} -> @@ -503,4 +494,6 @@ defmodule GamendWeb.SignalingChannel do defp validate_signal_payload(_value, socket), do: {:reply, {:error, %{error: "invalid_payload"}}, socket} + + defp rate_setting(key), do: Gamend.Settings.get(GamendWeb.Plugs.RateLimiter, key) end diff --git a/apps/gamend_web/lib/gamend_web/channels/user_channel.ex b/apps/gamend_web/lib/gamend_web/channels/user_channel.ex index 860061bc0..f9c4206ce 100644 --- a/apps/gamend_web/lib/gamend_web/channels/user_channel.ex +++ b/apps/gamend_web/lib/gamend_web/channels/user_channel.ex @@ -46,6 +46,7 @@ defmodule GamendWeb.UserChannel do alias Gamend.Accounts alias Gamend.Accounts.Scope + alias Gamend.Accounts.StalePresenceSweeper alias Gamend.Accounts.User alias Gamend.Friends alias Gamend.Groups @@ -65,9 +66,9 @@ defmodule GamendWeb.UserChannel do # Separate ICE candidate budget — prevents ICE flooding from starving # other channel events. A typical WebRTC session sends 5–30 candidates. - # Interval for periodic presence refresh (keeps StalePresenceSweeper from - # marking actively connected users as offline). Default: 3 minutes. - @presence_refresh_interval :timer.minutes(3) + # Periodic presence refresh keeps StalePresenceSweeper from marking actively + # connected users as offline; its interval follows the sweeper's threshold + # (`Gamend.Accounts.StalePresenceSweeper.heartbeat_ms/0`). @impl true def join("user:" <> user_id_str, _payload, socket) do @@ -321,7 +322,7 @@ defmodule GamendWeb.UserChannel do # Start periodic presence refresh so the StalePresenceSweeper doesn't # mark this user offline while the WebSocket is still open. - Process.send_after(self(), :refresh_presence, @presence_refresh_interval) + Process.send_after(self(), :refresh_presence, StalePresenceSweeper.heartbeat_ms()) {:noreply, socket} end @@ -372,7 +373,7 @@ defmodule GamendWeb.UserChannel do def handle_info(:refresh_presence, socket) do Accounts.touch_last_seen_by_id(socket.assigns.current_scope.user_id) - Process.send_after(self(), :refresh_presence, @presence_refresh_interval) + Process.send_after(self(), :refresh_presence, StalePresenceSweeper.heartbeat_ms()) {:noreply, socket} end diff --git a/apps/gamend_web/lib/gamend_web/channels/webrtc_peer.ex b/apps/gamend_web/lib/gamend_web/channels/webrtc_peer.ex index 8f7eb5528..b07f03eab 100644 --- a/apps/gamend_web/lib/gamend_web/channels/webrtc_peer.ex +++ b/apps/gamend_web/lib/gamend_web/channels/webrtc_peer.ex @@ -120,11 +120,7 @@ defmodule GamendWeb.WebRTCPeer do user = Accounts.get_user(user_id) - ice_servers = - Keyword.get_lazy(opts, :ice_servers, fn -> - webrtc_config = Application.get_env(:gamend_web, :webrtc, []) - Keyword.get(webrtc_config, :ice_servers, [%{urls: "stun:stun.l.google.com:19302"}]) - end) + ice_servers = Keyword.get_lazy(opts, :ice_servers, &GamendWeb.WebRTC.ice_servers/0) {:ok, pc} = PeerConnection.start_link(ice_servers: ice_servers) diff --git a/apps/gamend_web/lib/gamend_web/controllers/api/v1/me_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/api/v1/me_controller.ex index a39d1cbf4..78f5900a4 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/api/v1/me_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/api/v1/me_controller.ex @@ -261,7 +261,11 @@ defmodule GamendWeb.Api.V1.MeController do summary: "Delete current user", description: "Deletes the authenticated user's account. An account with a password sends " <> - "it as `current_password`; a device or OAuth-only one sends nothing.", + "it as `current_password`; a device or OAuth-only one sends nothing. When the " <> + "server keeps a grace period (`GAMEND_AUTH_DELETION_GRACE_DAYS`), the account is " <> + "instead scheduled for deletion that many days out and signed out everywhere; " <> + "API sign-ins answer 403 `deletion_scheduled` until then, and signing in on the " <> + "website keeps the account.", security: [%{"authorization" => []}], request_body: { "Current password, for an account that has one", @@ -296,8 +300,12 @@ defmodule GamendWeb.Api.V1.MeController do # wallet is the larger of the two actions. Accounts with no password (device # and OAuth-only) have nothing to prove, exactly as for setting one. if password_change_authorized?(user, params) do - case Gamend.Accounts.delete_user(user) do - {:ok, _} -> + case Gamend.Accounts.request_deletion(user) do + {:ok, :deleted} -> + reply_ok(conn) + + {:ok, {:scheduled, _user, expired_tokens}} -> + GamendWeb.UserAuth.disconnect_sessions(expired_tokens) reply_ok(conn) {:error, _} -> diff --git a/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex index f6bade23a..51dad2475 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/api/v1/session_controller.ex @@ -35,25 +35,38 @@ defmodule GamendWeb.Api.V1.SessionController do responses: [ ok: {"Login successful", "application/json", SessionResponse}, unauthorized: Schemas.error("Invalid credentials"), - forbidden: Schemas.error("Account awaiting activation") + forbidden: Schemas.error("Account awaiting activation, or scheduled for deletion"), + too_many_requests: + Schemas.error( + "Too many failed passwords for this email: password sign-in is locked for the " <> + "number of seconds in Retry-After (`account_locked`)" + ) ] ) def create(conn, %{"email" => email, "password" => password}) do - if user = Accounts.get_user_by_email_and_password(email, password) do - if Accounts.user_activated?(user) do - maybe_attach_device(conn, user) - issue_tokens(conn, user) - else - reply_error( - conn, - :forbidden, - "account_not_activated", - "Your account is pending activation by an administrator." + case Accounts.authenticate_by_password(email, password) do + {:ok, user} -> + case Tokens.refusal(user) do + nil -> + maybe_attach_device(conn, user) + issue_tokens(conn, user) + + {status, code, message} -> + reply_error(conn, status, code, message) + end + + {:error, {:locked, seconds}} -> + conn + |> put_resp_header("retry-after", Integer.to_string(seconds)) + |> reply_error( + :too_many_requests, + "account_locked", + "Too many failed sign-in attempts. Try again later, or sign in with an emailed link." ) - end - else - reply_error(conn, :unauthorized, "invalid_credentials", "Invalid email or password") + + {:error, :invalid_credentials} -> + reply_error(conn, :unauthorized, "invalid_credentials", "Invalid email or password") end end @@ -61,8 +74,9 @@ defmodule GamendWeb.Api.V1.SessionController do operation_id: "register", summary: "Register", description: - "Create an account with an email and a password, send its confirmation email " <> + "Create an account with an email and a password, queue its confirmation email " <> "as browser sign-up does, and sign it in: the tokens come back as from login. " <> + "The response does not wait for the email, which is sent and retried in the background. " <> "The first account becomes the admin and is confirmed without an email; account " <> "activation applies as for every sign-up. When the server requires it " <> "(`GAMEND_CAPTCHA_API_REGISTER`), a Cloudflare Turnstile token goes in `captcha_token`.", @@ -94,11 +108,13 @@ defmodule GamendWeb.Api.V1.SessionController do created: {"Account created and signed in", "application/json", SessionResponse}, bad_request: Schemas.error("Email or password missing (missing_param)"), forbidden: - Schemas.error("The account awaits activation by an admin, or the captcha failed"), + Schemas.error( + "The account awaits activation by an admin, the captcha failed, or a plugin " <> + "refused the sign-up (registration_refused)" + ), conflict: Schemas.error("Email or username already taken"), unprocessable_entity: Schemas.error("Invalid email, username or password"), - service_unavailable: - Schemas.error("The confirmation email could not be sent, or the captcha check could not") + service_unavailable: Schemas.error("The captcha check could not be completed") ] ) @@ -157,14 +173,11 @@ defmodule GamendWeb.Api.V1.SessionController do else: unprocessable(conn, changeset) end - # The mail did not go, and the account was rolled back with it. - defp registered({:error, _reason}, conn) do - reply_error( - conn, - :service_unavailable, - "email_delivery_failed", - "The confirmation email could not be sent" - ) + # A `before_user_register` plugin refused the sign-up. The email is queued, + # never sent here, so it cannot fail this request. + defp registered({:error, reason}, conn) do + message = if is_binary(reason), do: reason, else: "The registration was refused" + reply_error(conn, :forbidden, "registration_refused", message) end # An email or a username someone already has: 409, the input was fine. @@ -193,7 +206,10 @@ defmodule GamendWeb.Api.V1.SessionController do responses: [ ok: {"Login successful", "application/json", SessionResponse}, bad_request: Schemas.error("Unable to create device user"), - forbidden: Schemas.error("Device auth disabled, or account awaiting activation") + forbidden: + Schemas.error( + "Device auth disabled, or account awaiting activation or scheduled for deletion" + ) ] ) @@ -205,15 +221,9 @@ defmodule GamendWeb.Api.V1.SessionController do if Accounts.device_auth_enabled?() do case Accounts.find_or_create_from_device(device_id) do {:ok, user} -> - if Accounts.user_activated?(user) do - issue_tokens(conn, user) - else - reply_error( - conn, - :forbidden, - "account_not_activated", - "Your account is pending activation by an administrator." - ) + case Tokens.refusal(user) do + nil -> issue_tokens(conn, user) + {status, code, message} -> reply_error(conn, status, code, message) end {:error, changeset} -> diff --git a/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex index faae522cf..1226431b9 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex @@ -65,17 +65,31 @@ defmodule GamendWeb.Api.V1.StorageController do def upload(conn, _), do: reply_error(conn, :bad_request, "missing_param", "token is required") - @doc "GET /storage/*key — serve a stored object (local backend)." + @doc """ + GET /storage/*key — serve a stored object. The local backend serves the + bytes; any other backend redirects to a signed link (`Storage.url/2` hands + out this path for a private S3 bucket, since a signed link expires). + """ def show(conn, %{"key" => segments}) do key = Enum.join(segments, "/") - if publicly_servable?(key) do - serve_object(conn, key) - else - reply_error(conn, :not_found, "not_found") + cond do + not publicly_servable?(key) -> reply_error(conn, :not_found, "not_found") + Storage.adapter() == Storage.Local -> serve_object(conn, key) + true -> redirect_to_object(conn, key) end end + # Cached for half the link's life, so a cached redirect never points at an + # expired link. + defp redirect_to_object(conn, key) do + max_age = div(Storage.signed_url_seconds(), 2) + + conn + |> put_resp_header("cache-control", "public, max-age=#{max_age}") + |> redirect(external: Storage.url(key, signed: true)) + end + # Prefixes this unauthenticated route may serve. # # It used to serve *any* key in the store. Avatar and icon keys carry 16 bytes diff --git a/apps/gamend_web/lib/gamend_web/controllers/auth_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/auth_controller.ex index 46346c7f7..f5a00934c 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/auth_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/auth_controller.ex @@ -204,13 +204,9 @@ defmodule GamendWeb.AuthController do defp sign_in_session_outcome(user_params, config) do case config.finder.(user_params) do {:ok, user} -> - if Accounts.user_activated?(user) do - %{status: "completed", data: Tokens.sign_in(user)} - else - session_error( - "account_not_activated", - "Your account is pending activation by an administrator." - ) + case Tokens.refusal(user) do + nil -> %{status: "completed", data: Tokens.sign_in(user)} + {_status, code, message} -> session_error(code, message) end {:error, _changeset} -> @@ -229,15 +225,9 @@ defmodule GamendWeb.AuthController do case config.finder.(user_params) do {:ok, user} -> - if Accounts.user_activated?(user) do - reply_data(conn, Tokens.sign_in(user)) - else - reply_error( - conn, - :forbidden, - "account_not_activated", - "Your account is pending activation by an administrator." - ) + case Tokens.refusal(user) do + nil -> reply_data(conn, Tokens.sign_in(user)) + {status, code, message} -> reply_error(conn, status, code, message) end {:error, changeset} -> @@ -726,7 +716,7 @@ defmodule GamendWeb.AuthController do responses: [ ok: {"Signed in", "application/json", Schemas.SessionResponse}, bad_request: Schemas.error("Missing code, or the provider refused it"), - forbidden: Schemas.error("Account awaiting activation"), + forbidden: Schemas.error("Account awaiting activation, or scheduled for deletion"), not_found: Schemas.error("Unknown or disabled provider"), unprocessable_entity: Schemas.error("The account could not be created") ] @@ -765,7 +755,7 @@ defmodule GamendWeb.AuthController do responses: [ ok: {"Signed in", "application/json", Schemas.SessionResponse}, bad_request: Schemas.error("Missing or invalid token"), - forbidden: Schemas.error("Account awaiting activation"), + forbidden: Schemas.error("Account awaiting activation, or scheduled for deletion"), unprocessable_entity: Schemas.error("The account could not be created"), service_unavailable: Schemas.error("Google sign-in not configured") ] @@ -813,7 +803,7 @@ defmodule GamendWeb.AuthController do responses: [ ok: {"Signed in", "application/json", Schemas.SessionResponse}, bad_request: Schemas.error("Missing code, or Apple refused it"), - forbidden: Schemas.error("Account awaiting activation"), + forbidden: Schemas.error("Account awaiting activation, or scheduled for deletion"), unprocessable_entity: Schemas.error("The account could not be created") ] ) @@ -899,7 +889,7 @@ defmodule GamendWeb.AuthController do %{ access_token: access_token, refresh_token: Map.get(data, "refresh_token", ""), - expires_in: Map.get(data, "expires_in", 900), + expires_in: Map.get(data, "expires_in", Tokens.access_ttl_seconds()), user_id: Map.get(data, "user_id", ""), username: Map.get(data, "username", ""), display_name: Map.get(data, "display_name", "") diff --git a/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex index b06e49b1e..d1f31ea16 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/user_session_controller.ex @@ -40,22 +40,41 @@ defmodule GamendWeb.UserSessionController do defp create(conn, %{"user" => user_params}, info) do %{"email" => email, "password" => password} = user_params - if user = Accounts.get_user_by_email_and_password(email, password) do - if Accounts.user_activated?(user) do + case Accounts.authenticate_by_password(email, password) do + {:ok, user} -> + if Accounts.user_activated?(user) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + conn + |> put_flash(:error, gettext("Your account is pending activation.")) + |> redirect(to: ~p"/users/log_in") + end + + # Says as much for an address with no account: the lock counts per + # address, so it discloses nothing about which ones are registered. + {:error, {:locked, seconds}} -> + minutes = div(seconds + 59, 60) + conn - |> put_flash(:info, info) - |> UserAuth.log_in_user(user, user_params) - else + |> put_flash( + :error, + ngettext( + "Too many failed attempts. Try again in %{count} minute, or sign in with an emailed link.", + "Too many failed attempts. Try again in %{count} minutes, or sign in with an emailed link.", + minutes + ) + ) + |> put_flash(:email, String.slice(email, 0, Gamend.Limits.get(:max_email))) + |> redirect(to: ~p"/users/log_in") + + {:error, :invalid_credentials} -> + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. conn - |> put_flash(:error, gettext("Your account is pending activation.")) + |> put_flash(:error, gettext("Failed")) + |> put_flash(:email, String.slice(email, 0, Gamend.Limits.get(:max_email))) |> redirect(to: ~p"/users/log_in") - end - else - # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - conn - |> put_flash(:error, gettext("Failed")) - |> put_flash(:email, String.slice(email, 0, 160)) - |> redirect(to: ~p"/users/log_in") end end diff --git a/apps/gamend_web/lib/gamend_web/endpoint.ex b/apps/gamend_web/lib/gamend_web/endpoint.ex index df0a059ed..b19e09835 100644 --- a/apps/gamend_web/lib/gamend_web/endpoint.ex +++ b/apps/gamend_web/lib/gamend_web/endpoint.ex @@ -36,6 +36,10 @@ defmodule GamendWeb.Endpoint do # redirect can touch it; before everything else so a plain-HTTP request # costs one 301 and nothing more. plug GamendWeb.Plugs.ForceSSL + # A host app's own plugs, ahead of everything that assumes the request is + # for this site: a second host name the app answers must not be sent to the + # canonical host, given a session, or have its trailing slash taken away. + plug :host_plugs # After ForceSSL so a plain-HTTP request to an alias costs one redirect to # https on the canonical host rather than two hops. plug GamendWeb.Plugs.CanonicalHost @@ -91,15 +95,43 @@ defmodule GamendWeb.Endpoint do plug GamendWeb.ResponseContract end - plug Plug.Parsers, + # Before the body is parsed: a request over its limit is refused without + # reading up to a megabyte of JSON or multipart first. CORS first, so a 429 + # still carries the headers a browser needs to let a web client read it. + plug GamendWeb.Plugs.DynamicCors + plug GamendWeb.Plugs.RateLimiter + + plug :parse_body + + plug Plug.MethodOverride + plug Plug.Head + + @parsers_opts [ parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], - length: 1_048_576, body_reader: {GamendWeb.Plugs.RawBodyReader, :read_body, []}, json_decoder: Phoenix.json_library() + ] - plug Plug.MethodOverride - plug Plug.Head + # The body limit is a setting (`GAMEND_HTTP_MAX_BODY_BYTES`), which a plug + # declared with `plug Plug.Parsers, length: ...` would fix at compile time. + # The parsers are built at runtime instead, once per limit. + defp parse_body(conn, _opts), do: Plug.Parsers.call(conn, parsers_opts()) + + defp parsers_opts do + length = GamendWeb.Http.max_body_bytes() + key = {__MODULE__, :parsers, length} + + case :persistent_term.get(key, nil) do + nil -> + opts = Plug.Parsers.init(Keyword.put(@parsers_opts, :length, length)) + :persistent_term.put(key, opts) + opts + + opts -> + opts + end + end @compiled_session_opts Plug.Session.init(@session_options) plug :maybe_session @@ -108,8 +140,6 @@ defmodule GamendWeb.Endpoint do defp maybe_session(conn, _opts), do: Plug.Session.call(conn, @compiled_session_opts) plug GamendWeb.Plugs.LocalePath - plug GamendWeb.Plugs.DynamicCors - plug GamendWeb.Plugs.RateLimiter # After the static plugs — a file that exists is served as asked for — and # before the router, so `/docs/intro/` becomes `/docs/intro` for every # route rather than each page checking its own spelling. @@ -193,6 +223,35 @@ defmodule GamendWeb.Endpoint do ) end + # `config :gamend_web, :host_plugs, [MyApp.GamesHost, {MyApp.Other, opts}]`. + # Each is `init/1`ed once per configuration and cached; the first to halt + # ends the request, as any plug in this pipeline would. + defp host_plugs(conn, _opts) do + Enum.reduce_while(compiled_host_plugs(), conn, fn {plug, opts}, conn -> + conn = plug.call(conn, opts) + if conn.halted, do: {:halt, conn}, else: {:cont, conn} + end) + end + + defp compiled_host_plugs do + configured = Application.get_env(:gamend_web, :host_plugs, []) + + case :persistent_term.get({__MODULE__, :host_plugs}, nil) do + {^configured, compiled} -> + compiled + + _ -> + compiled = + Enum.map(configured, fn + {plug, opts} -> {plug, plug.init(opts)} + plug -> {plug, plug.init([])} + end) + + :persistent_term.put({__MODULE__, :host_plugs}, {configured, compiled}) + compiled + end + end + defp dispatch_router(conn, _opts) do router = Application.get_env(:gamend_web, :router, GamendWeb.Router) router.call(conn, router.init([])) diff --git a/apps/gamend_web/lib/gamend_web/host_runtime.ex b/apps/gamend_web/lib/gamend_web/host_runtime.ex index 4d2beaf2f..f2005432e 100644 --- a/apps/gamend_web/lib/gamend_web/host_runtime.ex +++ b/apps/gamend_web/lib/gamend_web/host_runtime.ex @@ -155,19 +155,10 @@ defmodule GamendWeb.HostRuntime do # to the Log provider. Credentials are parse-validated here so a bad value # degrades to that Log fallback with one loud error instead of handing the # dispatcher a config it would crash-loop on. + # The push queue's concurrency is applied with the other queues', by + # Gamend.Jobs.oban_config/0. defp push_entries(setting) do - # The push queue lives in Oban's config, so the declared concurrency has - # to be copied across rather than read from the setting at runtime. - queue_entries = - case setting.(Gamend.Push, :queue_concurrency) do - concurrency when is_integer(concurrency) and concurrency > 0 -> - [{:gamend_core, Oban, [queues: [push: concurrency]]}] - - _ -> - [] - end - - queue_entries ++ fcm_entries(setting) ++ apns_entries(setting) + fcm_entries(setting) ++ apns_entries(setting) end # Secret env vars accept inline contents or a path to a file holding them. @@ -309,13 +300,11 @@ defmodule GamendWeb.HostRuntime do url -> redis_conn_opts_from_url(url) end - l1_opts = [ + local_opts = [ # Create new generation every 12 hours gc_interval: :timer.hours(12), - # Max 1M entries - max_size: 1_000_000, - # Max 500MB of memory - allocated_memory: 500_000_000, + max_size: setting.(Gamend.Cache.Settings, :max_entries), + allocated_memory: setting.(Gamend.Cache.Settings, :max_memory_mb) * 1_000_000, # Run size and memory checks every 10 seconds gc_memory_check_interval: :timer.seconds(10) ] @@ -323,7 +312,7 @@ defmodule GamendWeb.HostRuntime do levels = case cache_mode do :single -> - [{Gamend.Cache.L1, l1_opts}] + [{Gamend.Cache.L1, local_opts}] _ -> l2_level = @@ -338,17 +327,11 @@ defmodule GamendWeb.HostRuntime do {Gamend.Cache.L2.Redis, pool_size: pool_size, conn_opts: redis_conn_opts} _ -> - {Gamend.Cache.L2.Partitioned, - primary: [ - # Partitioned uses a local primary storage on each node. - gc_interval: :timer.hours(12), - max_size: 1_000_000, - allocated_memory: 500_000_000, - gc_memory_check_interval: :timer.seconds(10) - ]} + # Partitioned uses a local primary storage on each node. + {Gamend.Cache.L2.Partitioned, primary: local_opts} end - [{Gamend.Cache.L1, l1_opts}, l2_level] + [{Gamend.Cache.L1, local_opts}, l2_level] end [ @@ -537,10 +520,9 @@ defmodule GamendWeb.HostRuntime do guardian_secret_key = setting.(Gamend.Accounts, :guardian_secret_key) || secret_key_base - [ - {:gamend_web, GamendWeb.Auth.Guardian, - [issuer: "gamend", secret_key: guardian_secret_key, ttl: {15, :minutes}]} - ] + # Token lifetimes are not here: GamendWeb.Auth.Guardian reads them from + # the auth.*_token_ttl_* settings in every environment. + [{:gamend_web, GamendWeb.Auth.Guardian, [issuer: "gamend", secret_key: guardian_secret_key]}] end defp rate_limit_entries(setting) do diff --git a/apps/gamend_web/lib/gamend_web/http.ex b/apps/gamend_web/lib/gamend_web/http.ex index 481c1334e..5d5ecb3c5 100644 --- a/apps/gamend_web/lib/gamend_web/http.ex +++ b/apps/gamend_web/lib/gamend_web/http.ex @@ -36,4 +36,16 @@ defmodule GamendWeb.Http do doc: "Browser CORS/WebSocket origin allowlist. Empty allows any origin. Prefix an entry with `regex:` for a pattern." ) + + setting(:max_body_bytes, :integer, + default: 1_048_576, + doc: + "Largest request body the server reads (JSON, form or multipart), in bytes. Raise it " <> + "with any GAMEND_LIMITS_* size above 1 MB, or requests that size are refused first. " <> + "Local-backend uploads are capped by GAMEND_LIMITS_MAX_UPLOAD_BYTES instead." + ) + + @doc "Largest request body the endpoint parses, in bytes (`max_body_bytes`)." + @spec max_body_bytes() :: pos_integer() + def max_body_bytes, do: max(Gamend.Settings.get(__MODULE__, :max_body_bytes), 1) end diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex index 4c9290df8..ecdf9ce12 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex @@ -39,7 +39,7 @@ defmodule GamendWeb.AdminLive.ConfigDiagnostics do "Tournaments" => ~w(max_tournament_title max_tournament_description max_tournament_slug max_tournament_entries max_tournament_bracket_size)a, "Matchmaking" => - ~w(max_matchmaking_players max_matchmaking_params_size matchmaking_timeout_ms matchmaking_tick_ms)a, + ~w(max_matchmaking_players matchmaking_default_min_players matchmaking_default_max_players max_matchmaking_params_size matchmaking_timeout_ms matchmaking_tick_ms)a, "Ready checks" => ~w(ready_check_timeout_ms max_ready_check_participants)a } diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/lobbies.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/lobbies.ex index 045e3aa17..1307687f3 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/lobbies.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/lobbies.ex @@ -1,6 +1,7 @@ defmodule GamendWeb.AdminLive.Lobbies do use GamendWeb, :live_view + alias Gamend.Accounts.PasswordHash alias Gamend.Lobbies alias Gamend.Lobbies.SpectatorTracker alias Gamend.ReadyChecks @@ -858,7 +859,7 @@ defmodule GamendWeb.AdminLive.Lobbies do Map.put(params, "password_hash", nil) is_binary(password) and password != "" -> - Map.put(params, "password_hash", Bcrypt.hash_pwd_salt(password)) + Map.put(params, "password_hash", PasswordHash.hash(password)) true -> params diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/rate_limiting.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/rate_limiting.ex index 2f4b100a3..1973d7215 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/rate_limiting.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/rate_limiting.ex @@ -409,7 +409,7 @@ defmodule GamendWeb.AdminLive.RateLimiting do socket |> assign(:ip_bans, IpBan.list_bans()) |> assign(:ban_log, IpBan.list_log()) - |> put_flash(:info, "Banned IP #{ip_str}")} + |> put_flash(:info, "Banned #{GamendWeb.RateLimit.ip_key(ip_str)}")} end end @@ -502,7 +502,6 @@ defmodule GamendWeb.AdminLive.RateLimiting do # code reads it. `lv_*` are the LiveView login/register buckets # (`GamendWeb.LiveHelpers.check_rate_limit/2`), on the HTTP settings. defp bucket_limits do - rl = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) general = rate_setting(:general_limit) %{ @@ -514,9 +513,8 @@ defmodule GamendWeb.AdminLive.RateLimiting do "ws" => rate_setting(:ws_limit), "dc" => rate_setting(:dc_limit), "ice" => rate_setting(:ice_limit), - # SignalingChannel's own keys and fallbacks — not declared settings. - "signaling_ws" => Keyword.get(rl, :signaling_ws_limit, 300), - "signaling_ice" => Keyword.get(rl, :signaling_ice_limit, 150), + "signaling_ws" => rate_setting(:signaling_ws_limit), + "signaling_ice" => rate_setting(:signaling_ice_limit), "chatd" => positive_limit(:max_chat_messages_per_day, general), "chatrep" => positive_limit(:max_chat_reports_per_user_per_day, general) } diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/storage.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/storage.ex index 4c8a13e43..5e299b41d 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/storage.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/storage.ex @@ -255,7 +255,7 @@ defmodule GamendWeb.AdminLive.Storage do - + Download + + + + <.form for={@form} id="user-form" phx-submit="save_user"> <%!-- Read-only: `User.admin_changeset/2` does not cast `email`, so an edit here was dropped while the flash still said "updated". --%> @@ -635,6 +682,7 @@ defmodule GamendWeb.AdminLive.Users do |> assign(:selected_user, nil) |> assign(:form, nil) |> assign(:user_tokens, []) + |> assign(:login_locked, false) |> assign(:search_query, "") |> assign(:filters, initial_filters) |> assign(:sort_field, sort_field) @@ -653,7 +701,31 @@ defmodule GamendWeb.AdminLive.Users do socket |> assign(:selected_user, user) |> assign(:form, form) - |> assign(:user_tokens, tokens)} + |> assign(:user_tokens, tokens) + |> assign(:login_locked, login_locked?(user))} + end + + def handle_event("cancel_user_deletion", %{"id" => id}, socket) do + case Accounts.cancel_deletion(Accounts.get_user!(id)) do + {:ok, user} -> + {:noreply, + socket + |> assign(:selected_user, user) + |> put_flash(:info, "The account will not be deleted")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed")} + end + end + + def handle_event("unlock_login", %{"id" => id}, socket) do + user = Accounts.get_user!(id) + :ok = LoginLockouts.clear(user.email) + + {:noreply, + socket + |> assign(:login_locked, false) + |> put_flash(:info, "Password sign-in unlocked")} end # Search / filter handlers @@ -761,7 +833,8 @@ defmodule GamendWeb.AdminLive.Users do socket |> assign(:selected_user, nil) |> assign(:form, nil) - |> assign(:user_tokens, [])} + |> assign(:user_tokens, []) + |> assign(:login_locked, false)} end def handle_event("revoke_token", %{"id" => id}, socket) do @@ -1088,6 +1161,11 @@ defmodule GamendWeb.AdminLive.Users do defp session_count(tokens), do: Enum.count(tokens, &(&1.context == "session")) + defp login_locked?(%User{email: email}) when is_binary(email), + do: LoginLockouts.check(email) != :ok + + defp login_locked?(_user), do: false + # Delegates to Accounts.list_all_users/2 (the reusable, admin-scoped context # query) so search/filter/sort logic lives in one place, shared with anything # else that needs an admin user listing. The sort is passed on every call: diff --git a/apps/gamend_web/lib/gamend_web/live/play_live.ex b/apps/gamend_web/lib/gamend_web/live/play_live.ex index 6fcad473d..7aa92c51e 100644 --- a/apps/gamend_web/lib/gamend_web/live/play_live.ex +++ b/apps/gamend_web/lib/gamend_web/live/play_live.ex @@ -86,7 +86,7 @@ defmodule GamendWeb.PlayLive do with {:ok, access_token, _claims} <- Guardian.encode_and_sign(user, %{}, token_type: "access"), {:ok, refresh_token, _claims} <- - Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {30, :days}) do + Guardian.encode_and_sign(user, %{}, token_type: "refresh") do %{access_token: access_token, refresh_token: refresh_token} else _error -> %{} diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex b/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex index 05a7ddb80..929709ec8 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/registration.ex @@ -122,8 +122,8 @@ defmodule GamendWeb.UserLive.Registration do ) |> push_navigate(to: ~p"/users/log_in/#{token}")} else - # Not the first user: a confirmation email was sent inside the - # registration transaction. Inform the user to check their inbox. + # Not the first user: the confirmation email is queued and goes out + # in the background. Inform the user to check their inbox. {:noreply, socket |> put_flash( @@ -137,8 +137,8 @@ defmodule GamendWeb.UserLive.Registration do {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} {:error, reason} -> - # If email delivery failed the user creation was rolled back. Keep the - # form open and present a friendly error message. + # A `before_user_register` plugin refused the sign-up (the email is + # queued, so it cannot fail here). Keep the form open. require Logger Logger.error("register_user_and_deliver failed: #{inspect(reason)}") diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/settings.ex b/apps/gamend_web/lib/gamend_web/live/user_live/settings.ex index 9dbdf0e05..98031b540 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/settings.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/settings.ex @@ -13,6 +13,7 @@ defmodule GamendWeb.UserLive.Settings do alias Gamend.Friends alias Gamend.Groups alias GamendWeb.UserLive.Settings.AccountTab + alias GamendWeb.UserLive.Settings.ApiTokensTab alias GamendWeb.UserLive.Settings.DataTab alias GamendWeb.UserLive.Settings.DevicesTab alias GamendWeb.UserLive.Settings.FriendsTab @@ -22,7 +23,7 @@ defmodule GamendWeb.UserLive.Settings do alias GamendWeb.UserLive.Settings.Shared alias GamendWeb.UserLive.Settings.WalletTab - @valid_tabs ~w(account friends groups wallet items payments data devices) + @valid_tabs ~w(account friends groups wallet items payments data devices api_tokens) @account_events ~w(validate_email update_email validate_display_name update_display_name validate_username update_username validate_avatar save_avatar cancel_avatar @@ -32,11 +33,13 @@ defmodule GamendWeb.UserLive.Settings do cancel_friend remove_friend unblock_friend search_prev search_next incoming_prev incoming_next outgoing_prev outgoing_next friends_prev friends_next blocked_prev blocked_next) - @payments_events ~w(cancel_stripe_subscription) + @payments_events ~w(cancel_stripe_subscription open_stripe_portal) @wallet_events ~w(wallet_ledger_prev wallet_ledger_next) @items_events ~w(items_prev items_next) @data_events ~w(kv_prev kv_next kv_filters_change kv_filters_apply kv_filters_clear) @devices_events ~w(devices_prev devices_next device_remove) + @api_tokens_events ~w(api_token_create api_token_dismiss api_token_revoke api_tokens_prev + api_tokens_next) @groups_events ~w(groups_tab groups_toggle_create group_validate_create group_create group_leave group_join group_request_join group_accept_invite group_decline_invite group_cancel_request group_cancel_invite @@ -95,7 +98,8 @@ defmodule GamendWeb.UserLive.Settings do {"items", gettext("Items")}, {"payments", gettext("Payments")}, {"data", gettext("Data")}, - {"devices", gettext("Devices")} + {"devices", gettext("Devices")}, + {"api_tokens", gettext("API tokens")} ] } phx-click="settings_tab" @@ -119,6 +123,7 @@ defmodule GamendWeb.UserLive.Settings do + """ @@ -157,6 +162,7 @@ defmodule GamendWeb.UserLive.Settings do |> FriendsTab.assign_defaults(user) |> DataTab.assign_defaults() |> DevicesTab.assign_defaults() + |> ApiTokensTab.assign_defaults() |> WalletTab.assign_defaults() |> ItemsTab.assign_defaults() |> GroupsTab.assign_defaults() @@ -197,6 +203,9 @@ defmodule GamendWeb.UserLive.Settings do def handle_event(event, params, socket) when event in @devices_events, do: DevicesTab.handle_event(event, params, socket) + def handle_event(event, params, socket) when event in @api_tokens_events, + do: ApiTokensTab.handle_event(event, params, socket) + def handle_event(event, params, socket) when event in @groups_events, do: GroupsTab.handle_event(event, params, socket) diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/settings/account_tab.ex b/apps/gamend_web/lib/gamend_web/live/user_live/settings/account_tab.ex index 578d1ce84..36f297ae2 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/settings/account_tab.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/settings/account_tab.ex @@ -255,9 +255,10 @@ defmodule GamendWeb.UserLive.Settings.AccountTab do
@@ -435,13 +436,28 @@ defmodule GamendWeb.UserLive.Settings.AccountTab do def handle_event("delete_user", _params, socket) do user = Shared.current_user(socket) - case Accounts.delete_user(user) do - {:ok, _deleted_user} -> + case Accounts.request_deletion(user) do + {:ok, :deleted} -> {:noreply, socket |> put_flash(:info, gettext("Success.")) |> redirect(external: ~p"/")} + {:ok, {:scheduled, _user, expired_tokens}} -> + GamendWeb.UserAuth.disconnect_sessions(expired_tokens) + + {:noreply, + socket + |> put_flash( + :info, + ngettext( + "Your account will be deleted in %{count} day. Sign in before then to keep it.", + "Your account will be deleted in %{count} days. Sign in before then to keep it.", + Accounts.deletion_grace_days() + ) + ) + |> redirect(external: ~p"/")} + {:error, _changeset} -> {:noreply, put_flash(socket, :error, gettext("Failed"))} end @@ -590,6 +606,17 @@ defmodule GamendWeb.UserLive.Settings.AccountTab do end end + defp delete_confirmation(0), + do: gettext("Delete your account permanently? This cannot be undone.") + + defp delete_confirmation(days) do + ngettext( + "Delete your account? It will be deleted in %{count} day, and signing in before then keeps it.", + "Delete your account? It will be deleted in %{count} days, and signing in before then keeps it.", + days + ) + end + # Rows in the Account card: every linked provider (a disabled one must stay # unlinkable) plus every enabled one. # Brand names: never translated. `capitalize/1` covers every provider but diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/settings/api_tokens_tab.ex b/apps/gamend_web/lib/gamend_web/live/user_live/settings/api_tokens_tab.ex new file mode 100644 index 000000000..0492b0f99 --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/live/user_live/settings/api_tokens_tab.ex @@ -0,0 +1,240 @@ +defmodule GamendWeb.UserLive.Settings.ApiTokensTab do + @moduledoc """ + API tokens tab of the user settings page: create a personal token, see the + ones that exist, revoke them (`Gamend.Accounts.ApiTokens`). + + A new token is shown once, in `@api_token_created`, and never again — the + row keeps only a hash. Dismissing the notice drops it from the socket too. + """ + + use GamendWeb, :html + import Phoenix.LiveView, only: [put_flash: 3] + + alias Gamend.Accounts.ApiToken + alias Gamend.Accounts.ApiTokens + alias GamendWeb.LiveHelpers + + @page_size 25 + + def assign_defaults(socket) do + socket + |> assign(:api_tokens_page, 1) + |> assign(:api_token_created, nil) + |> assign(:api_token_form, blank_form()) + |> reload_api_tokens() + end + + def tab(assigns) do + ~H""" +
+
+
+
{gettext("API tokens")}
+
+ {gettext( + "For scripts and CI: send one as a Bearer token to any API route that takes an access token. A password or email change revokes them all." + )} +
+
+ + + + <.form + for={@api_token_form} + id="api-token-form" + phx-submit="api_token_create" + class="flex flex-wrap items-end gap-3" + > +
+ <.input + field={@api_token_form[:name]} + type="text" + label={gettext("Name")} + placeholder={gettext("e.g. release script")} + required + /> +
+
+ <.input + field={@api_token_form[:expires_in_days]} + type="select" + label={gettext("Expires")} + options={expiry_options()} + /> +
+ + + +
+ {gettext("No API tokens.")} +
+ +
+ + + + + + + + + + + + + token.id}> + + + + + + + + +
{gettext("Name")}{gettext("Token")}{gettext("Created")}{gettext("Last used")}{gettext("Expires")}
{token.name}{ApiTokens.prefix()}{token.hint}…<.timestamp at={token.inserted_at} /> + <.timestamp at={token.last_used_at} empty={gettext("Never")} /> + + <%= cond do %> + <% ApiTokens.superseded?(token, @user) -> %> + + {gettext("Revoked by a password or email change")} + + <% ApiTokens.expired?(token) -> %> + {gettext("Expired")} + <% is_nil(ApiTokens.expires_at(token)) -> %> + {gettext("Never")} + <% true -> %> + <.timestamp at={ApiTokens.expires_at(token)} /> + <% end %> + + +
+
+ +
1}> + <.pagination + page={@api_tokens_page} + total_pages={@api_tokens_total_pages} + total_count={@api_tokens_count} + on_prev="api_tokens_prev" + on_next="api_tokens_next" + /> +
+
+
+ """ + end + + def handle_event("api_token_create", %{"api_token" => params}, socket) do + attrs = %{ + "name" => params["name"], + "expires_in_days" => parse_days(params["expires_in_days"]) + } + + case ApiTokens.create(socket.assigns.user, attrs) do + {:ok, token, _row} -> + {:noreply, + socket + |> assign(:api_token_created, token) + |> assign(:api_token_form, blank_form()) + |> assign(:api_tokens_page, 1) + |> reload_api_tokens()} + + {:error, :limit_reached} -> + {:noreply, + put_flash(socket, :error, gettext("You have reached the token limit. Revoke one first."))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :api_token_form, to_form(changeset, as: :api_token))} + end + end + + def handle_event("api_token_dismiss", _params, socket) do + {:noreply, assign(socket, :api_token_created, nil)} + end + + def handle_event("api_token_revoke", %{"id" => id}, socket) do + case ApiTokens.revoke(socket.assigns.user.id, to_string(id)) do + {:ok, _} -> + {:noreply, socket |> put_flash(:info, gettext("Token revoked.")) |> reload_api_tokens()} + + {:error, _} -> + {:noreply, put_flash(socket, :error, gettext("Failed"))} + end + end + + def handle_event("api_tokens_prev", _params, socket) do + page = max(1, (socket.assigns.api_tokens_page || 1) - 1) + {:noreply, socket |> assign(:api_tokens_page, page) |> reload_api_tokens()} + end + + def handle_event("api_tokens_next", _params, socket) do + page = (socket.assigns.api_tokens_page || 1) + 1 + {:noreply, socket |> assign(:api_tokens_page, page) |> reload_api_tokens()} + end + + @doc "Reloads the token list for the current page." + def reload_api_tokens(socket) do + page = socket.assigns[:api_tokens_page] || 1 + user = socket.assigns.user + count = ApiTokens.count(user.id) + + socket + |> assign(:api_tokens, ApiTokens.list(user.id, page: page, page_size: @page_size)) + |> assign(:api_tokens_count, count) + |> assign(:api_tokens_total_pages, LiveHelpers.total_pages(count, @page_size)) + end + + defp blank_form do + to_form(%{"name" => "", "expires_in_days" => default_expiry()}, as: :api_token) + end + + # 90 days when it is offered, else the longest lifetime that expires. + defp default_expiry do + days = ApiToken.expiry_choices() |> Enum.reject(&is_nil/1) + to_string(if 90 in days, do: 90, else: List.last(days)) + end + + defp expiry_options do + Enum.map(ApiToken.expiry_choices(), fn + nil -> {gettext("Never"), "never"} + days -> {ngettext("In %{count} day", "In %{count} days", days), to_string(days)} + end) + end + + defp parse_days("never"), do: nil + + defp parse_days(value) when is_binary(value) do + case Integer.parse(value) do + {days, ""} -> days + _ -> -1 + end + end + + defp parse_days(_), do: -1 +end diff --git a/apps/gamend_web/lib/gamend_web/live/user_live/settings/payments_tab.ex b/apps/gamend_web/lib/gamend_web/live/user_live/settings/payments_tab.ex index 4cee87dd1..4805192d3 100644 --- a/apps/gamend_web/lib/gamend_web/live/user_live/settings/payments_tab.ex +++ b/apps/gamend_web/lib/gamend_web/live/user_live/settings/payments_tab.ex @@ -18,10 +18,12 @@ defmodule GamendWeb.UserLive.Settings.PaymentsTab do socket |> assign(:payment_purchases, Payments.list_user_purchases(user.id, limit: 100)) |> assign(:payment_entitlements, Payments.list_user_entitlements(user.id)) + |> assign(:stripe_customer?, is_binary(Payments.stripe_customer_id(user))) else socket |> assign(:payment_purchases, []) |> assign(:payment_entitlements, []) + |> assign(:stripe_customer?, false) end end @@ -33,9 +35,21 @@ defmodule GamendWeb.UserLive.Settings.PaymentsTab do
{gettext("Payments")}
- <.link navigate={~p"/store"} class="btn btn-sm btn-primary"> - {gettext("Open Store")} - +
+ <%!-- Stripe's hosted portal: cancel, change card, invoices. --%> + + <.link navigate={~p"/store"} class="btn btn-sm btn-primary"> + {gettext("Open Store")} + +
@@ -172,6 +186,17 @@ defmodule GamendWeb.UserLive.Settings.PaymentsTab do """ end + def handle_event("open_stripe_portal", _params, socket) do + user = Shared.current_user(socket) + return_url = GamendWeb.Endpoint.url() <> ~p"/users/settings?tab=payments" + + case user && Payments.create_stripe_billing_portal(user, return_url) do + {:ok, url} -> {:noreply, redirect(socket, external: url)} + {:error, reason} -> {:noreply, put_flash(socket, :error, payment_error(reason))} + nil -> {:noreply, socket} + end + end + def handle_event("cancel_stripe_subscription", %{"id" => id}, socket) do user = Shared.current_user(socket) diff --git a/apps/gamend_web/lib/gamend_web/live_helpers.ex b/apps/gamend_web/lib/gamend_web/live_helpers.ex index 3298f9e4b..6425b6a52 100644 --- a/apps/gamend_web/lib/gamend_web/live_helpers.ex +++ b/apps/gamend_web/lib/gamend_web/live_helpers.ex @@ -145,12 +145,12 @@ defmodule GamendWeb.LiveHelpers do def check_rate_limit(ip, :auth) do {limit, window} = auth_limits() - do_check("lv_auth:#{ip}", window, limit) + do_check("lv_auth:#{GamendWeb.RateLimit.ip_key(ip)}", window, limit) end def check_rate_limit(ip, :general) do {limit, window} = general_limits() - do_check("lv_general:#{ip}", window, limit) + do_check("lv_general:#{GamendWeb.RateLimit.ip_key(ip)}", window, limit) end defp do_check(key, window_ms, limit) do diff --git a/apps/gamend_web/lib/gamend_web/plugs/ip_ban.ex b/apps/gamend_web/lib/gamend_web/plugs/ip_ban.ex index 1810c68ae..1d9119059 100644 --- a/apps/gamend_web/lib/gamend_web/plugs/ip_ban.ex +++ b/apps/gamend_web/lib/gamend_web/plugs/ip_ban.ex @@ -17,6 +17,11 @@ defmodule GamendWeb.Plugs.IpBan do This plug runs early in the endpoint pipeline, after `RealIp` extracts the true client address. + + An IPv6 address is banned by its /64 (`GamendWeb.RateLimit.ip_key/1`): one + subscriber is routinely handed the whole /64 and moves around inside it, so + a ban on a single address would not hold. `ban("2001:db8::1")` is listed as + `2001:db8::/64`. An IPv4 address is banned as itself. """ import Plug.Conn @@ -56,6 +61,7 @@ defmodule GamendWeb.Plugs.IpBan do """ def ban(ip, ttl_ms \\ :infinity) do init_table() + ip = GamendWeb.RateLimit.ip_key(ip) expires_at = case ttl_ms do @@ -76,8 +82,16 @@ defmodule GamendWeb.Plugs.IpBan do :ok end - @doc "Remove a ban for the given IP (locally, persisted, and cluster-wide)." + @doc """ + Remove a ban for the given IP (locally, persisted, and cluster-wide): the + ban on its /64 for IPv6, and any on the exact address, which is how IPv6 + bans were stored before they covered the /64. + """ def unban(ip) do + [GamendWeb.RateLimit.ip_key(ip), ip] |> Enum.uniq() |> Enum.each(&unban_key/1) + end + + defp unban_key(ip) do init_table() :ets.delete(@table, ip) append_log(:unban, ip, nil) @@ -86,8 +100,12 @@ defmodule GamendWeb.Plugs.IpBan do :ok end - @doc "Check if an IP is currently banned." + @doc "Check if an IP is currently banned: itself, or its /64 for IPv6." def banned?(ip) do + [ip, GamendWeb.RateLimit.ip_key(ip)] |> Enum.uniq() |> Enum.any?(&banned_key?/1) + end + + defp banned_key?(ip) do init_table() case :ets.lookup(@table, ip) do @@ -217,8 +235,9 @@ defmodule GamendWeb.Plugs.IpBan do @impl true def call(conn, _opts) do ip = conn.remote_ip |> :inet.ntoa() |> to_string() + network = GamendWeb.RateLimit.ip_key(conn.remote_ip) - if banned?(ip) do + if banned_key?(ip) or (network != ip and banned_key?(network)) do conn |> send_resp(403, "Forbidden") |> halt() diff --git a/apps/gamend_web/lib/gamend_web/plugs/locale_path.ex b/apps/gamend_web/lib/gamend_web/plugs/locale_path.ex index feddfdb2d..fe80964b8 100644 --- a/apps/gamend_web/lib/gamend_web/plugs/locale_path.ex +++ b/apps/gamend_web/lib/gamend_web/plugs/locale_path.ex @@ -193,6 +193,15 @@ defmodule GamendWeb.Plugs.LocalePath do with_query(path, drop_switch_param(conn.query_string)) end + @doc """ + `path_info` without a leading locale segment (`["ro", "users", "log_in"]` is + `["users", "log_in"]`), for code that runs before this plug and must see the + path the router will. `GamendWeb.Plugs.RateLimiter` is one: it runs before + the body is parsed, which this plug, needing the session, cannot. + """ + @spec strip_locale([String.t()]) :: [String.t()] + def strip_locale(path_info) when is_list(path_info), do: strip_locale_segment(path_info) + defp strip_locale_segment([first | rest]) when is_binary(first) do case GamendWeb.GettextSync.normalize_locale(first) do locale when is_binary(locale) and locale in @known_locales -> rest diff --git a/apps/gamend_web/lib/gamend_web/plugs/rate_limiter.ex b/apps/gamend_web/lib/gamend_web/plugs/rate_limiter.ex index 4e6c03ea5..ca92afa71 100644 --- a/apps/gamend_web/lib/gamend_web/plugs/rate_limiter.ex +++ b/apps/gamend_web/lib/gamend_web/plugs/rate_limiter.ex @@ -19,6 +19,8 @@ defmodule GamendWeb.Plugs.RateLimiter do import Plug.Conn + alias GamendWeb.Plugs.LocalePath + # Declared so the values are documented, env-fed and visible in the admin # viewer; the `Keyword.get(config(), key, @default)` reads below are # unchanged, because a setting resolves into the app-env key they already @@ -95,6 +97,28 @@ defmodule GamendWeb.Plugs.RateLimiter do doc: "ICE candidate window, in milliseconds." ) + # The peer-to-peer signaling channel has budgets of its own: relaying SDP and + # ICE between players is chattier than the user channel's traffic. + setting(:signaling_ws_limit, :integer, + default: 300, + doc: "Max signaling channel messages per window, per user." + ) + + setting(:signaling_ws_window_ms, :integer, + default: 10_000, + doc: "Signaling channel window, in milliseconds." + ) + + setting(:signaling_ice_limit, :integer, + default: 150, + doc: "Max ICE candidates relayed over the signaling channel per window, per user." + ) + + setting(:signaling_ice_window_ms, :integer, + default: 30_000, + doc: "Signaling ICE window, in milliseconds." + ) + def init(opts), do: opts def call(conn, _opts) do @@ -105,6 +129,12 @@ defmodule GamendWeb.Plugs.RateLimiter do end end + # This plug runs before `LocalePath` (so before the body is parsed), and so + # sees `/ro/users/log_in` where the router will see `/users/log_in`: strip the + # locale here, or a localized login would count against the general bucket. + defp routed_path(["api" | _] = path_info), do: path_info + defp routed_path(path_info), do: LocalePath.strip_locale(path_info) + # Skip rate limiting for internal/infrastructure endpoints defp skip_path?(%{path_info: ["metrics"]}), do: true defp skip_path?(%{path_info: ["health"]}), do: true @@ -112,7 +142,7 @@ defmodule GamendWeb.Plugs.RateLimiter do defp do_rate_limit(conn) do ip = client_ip(conn) - {bucket, scale, limit} = bucket_for(conn, ip) + {bucket, scale, limit} = bucket_for(%{conn | path_info: routed_path(conn.path_info)}, ip) case GamendWeb.RateLimit.hit(bucket, scale, limit) do {:allow, _count} -> @@ -211,10 +241,8 @@ defmodule GamendWeb.Plugs.RateLimiter do defp setting(key), do: Gamend.Settings.get(__MODULE__, key) # Real client IP is already extracted by the RealIp plug earlier in the - # endpoint pipeline, so we just format conn.remote_ip. - defp client_ip(conn) do - conn.remote_ip |> :inet.ntoa() |> to_string() - end + # endpoint pipeline; IPv6 clients are bucketed by /64 (`RateLimit.ip_key/1`). + defp client_ip(conn), do: GamendWeb.RateLimit.ip_key(conn.remote_ip) defp enabled?, do: setting(:enabled) end diff --git a/apps/gamend_web/lib/gamend_web/rate_limit.ex b/apps/gamend_web/lib/gamend_web/rate_limit.ex index 4dad8335e..434885a0b 100644 --- a/apps/gamend_web/lib/gamend_web/rate_limit.ex +++ b/apps/gamend_web/lib/gamend_web/rate_limit.ex @@ -24,6 +24,34 @@ defmodule GamendWeb.RateLimit do The configured backend is started in the host application supervision tree. """ + import Bitwise + + @doc """ + The bucket a client address is limited under: the address itself for IPv4, + its /64 for IPv6. + + An IPv6 subscriber is routinely handed a whole /64, 2^64 addresses, so a + limit per address would not hold for them. An IPv4-mapped address + (`::ffff:1.2.3.4`, what a dual-stack listener reports for an IPv4 client) + is keyed as the IPv4 it carries; taking its /64 would put every IPv4 client + in one bucket. A string that is not an address is its own key. + """ + @spec ip_key(:inet.ip_address() | String.t()) :: String.t() + def ip_key({_, _, _, _} = ip), do: ip |> :inet.ntoa() |> to_string() + + def ip_key({0, 0, 0, 0, 0, 0xFFFF, hi, lo}), + do: ip_key({hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}) + + def ip_key({a, b, c, d, _, _, _, _}), + do: "#{:inet.ntoa({a, b, c, d, 0, 0, 0, 0})}/64" + + def ip_key(ip) when is_binary(ip) do + case :inet.parse_address(String.to_charlist(ip)) do + {:ok, address} -> ip_key(address) + {:error, _} -> ip + end + end + @spec hit(String.t(), pos_integer(), pos_integer()) :: {:allow, non_neg_integer()} | {:deny, non_neg_integer()} def hit(key, scale, limit) do diff --git a/apps/gamend_web/lib/gamend_web/router/shared.ex b/apps/gamend_web/lib/gamend_web/router/shared.ex index b2036ec62..bb6a790b7 100644 --- a/apps/gamend_web/lib/gamend_web/router/shared.ex +++ b/apps/gamend_web/lib/gamend_web/router/shared.ex @@ -256,8 +256,8 @@ defmodule GamendWeb.Router.Shared do gamend_search_routes() - # Serve stored objects (local backend). With S3 the object URL points at the - # bucket and this route is unused. + # Serve stored objects. The local backend serves the bytes; a private S3 + # bucket (no public_url) redirects to a freshly signed link. scope "/", GamendWeb.Api.V1, as: :api_v1 do pipe_through :browser diff --git a/apps/gamend_web/lib/gamend_web/schemas/auth.ex b/apps/gamend_web/lib/gamend_web/schemas/auth.ex index 5afabeffb..4acc67b4b 100644 --- a/apps/gamend_web/lib/gamend_web/schemas/auth.ex +++ b/apps/gamend_web/lib/gamend_web/schemas/auth.ex @@ -11,8 +11,16 @@ defmodule GamendWeb.Schemas.Session do description: "Tokens for a signed-in user", type: :object, properties: %{ - access_token: %Schema{type: :string, description: "JWT access token (15 min)"}, - refresh_token: %Schema{type: :string, description: "JWT refresh token (30 days)"}, + access_token: %Schema{ + type: :string, + description: + "JWT access token (15 min by default: `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES`)" + }, + refresh_token: %Schema{ + type: :string, + description: + "JWT refresh token (30 days by default: `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS`)" + }, expires_in: %Schema{type: :integer, description: "Seconds until the access token expires"}, user_id: %Schema{type: :string, format: :uuid}, username: %Schema{type: :string, description: "Unique handle"}, diff --git a/apps/gamend_web/lib/gamend_web/uploads.ex b/apps/gamend_web/lib/gamend_web/uploads.ex index ece6a1e60..e7a516c5e 100644 --- a/apps/gamend_web/lib/gamend_web/uploads.ex +++ b/apps/gamend_web/lib/gamend_web/uploads.ex @@ -54,8 +54,11 @@ defmodule GamendWeb.Uploads do """ def token_salt, do: "storage upload" - @doc "How long an upload ticket stays valid, in seconds." - def token_max_age, do: 900 + @doc """ + How long an upload ticket stays valid, in seconds: the `expires_in` the ticket + answers, so a client is never told a longer window than the token allows. + """ + def token_max_age, do: Gamend.Storage.upload_ttl_seconds() # The local backend receives the upload on our own endpoint, so the key has to # travel signed: the receiver takes the key *from the token*, never from the diff --git a/apps/gamend_web/lib/gamend_web/user_auth.ex b/apps/gamend_web/lib/gamend_web/user_auth.ex index bd8c6eecf..bea29ac7c 100644 --- a/apps/gamend_web/lib/gamend_web/user_auth.ex +++ b/apps/gamend_web/lib/gamend_web/user_auth.ex @@ -15,34 +15,25 @@ defmodule GamendWeb.UserAuth do alias Gamend.Accounts alias Gamend.Accounts.Scope + alias Gamend.Accounts.UserToken - # Make the remember me cookie valid for 14 days. This should match - # the session validity setting in UserToken. - @max_cookie_age_in_days 14 + # The remember-me cookie lives exactly as long as the session token it holds: + # both come from `auth.session_days` (`UserToken.session_validity_in_days/0`). @remember_me_cookie "_gamend_web_user_remember_me" - @remember_me_options [ - sign: true, - max_age: @max_cookie_age_in_days * 24 * 60 * 60, - same_site: "Lax" - ] - - # How old the session token should be before a new one is issued. When a request is made - # with a session token older than this value, then a new session token will be created - # and the session and remember-me cookies (if set) will be updated with the new token. - # Lowering this value will result in more tokens being created by active users. Increasing - # it will result in less time before a session token expires for a user to get issued a new - # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable - # the reissuing of tokens completely. - @session_reissue_age_in_days 7 @doc """ Logs the user in. Redirects to the session's `:user_return_to` path or falls back to the `signed_in_path/1`. + + Signing in on the website is how an account scheduled for deletion is kept: + a person is at the keyboard here, where an API sign-in may be a game client + signing in on its own (`GamendWeb.Auth.Tokens.refusal/1`). """ def log_in_user(conn, user, params \\ %{}) do user_return_to = get_session(conn, :user_return_to) + {conn, user} = keep_scheduled_account(conn, user) conn = create_or_extend_session(conn, user, params) @@ -64,6 +55,21 @@ defmodule GamendWeb.UserAuth do conn |> redirect(to: user_return_to || signed_in_path(conn)) end + defp keep_scheduled_account(conn, user) do + if Accounts.deletion_scheduled?(user) do + case Accounts.cancel_deletion(user) do + {:ok, user} -> + {put_flash(conn, :info, gettext("Welcome back. Your account will not be deleted.")), + user} + + {:error, _changeset} -> + {conn, user} + end + else + {conn, user} + end + end + @doc """ Logs the user out. @@ -117,11 +123,13 @@ defmodule GamendWeb.UserAuth do end end - # Reissue the session token if it is older than the configured reissue age. + # A session token is reissued once it is half its validity old: an active + # user is never logged out, and an idle one lasts the whole window from + # their last visit. 7 days at the 14-day default. defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do - token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day) + token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at) - if token_age >= @session_reissue_age_in_days do + if token_age >= div(session_seconds(), 2) do create_or_extend_session(conn, user, %{}) else conn @@ -187,9 +195,15 @@ defmodule GamendWeb.UserAuth do defp write_remember_me_cookie(conn, token) do conn |> put_session(:user_remember_me, true) - |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) + |> put_resp_cookie(@remember_me_cookie, token, + sign: true, + max_age: session_seconds(), + same_site: "Lax" + ) end + defp session_seconds, do: UserToken.session_validity_in_days() * 86_400 + defp put_token_in_session(conn, token) do conn |> put_session(:user_token, token) @@ -288,7 +302,10 @@ defmodule GamendWeb.UserAuth do def on_mount(:require_sudo_mode, _params, session, socket) do socket = mount_current_scope(socket, session) - if Accounts.sudo_mode?(Scope.user(socket.assigns.current_scope), -10) do + if Accounts.sudo_mode?( + Scope.user(socket.assigns.current_scope), + -Accounts.sudo_mode_minutes() + ) do {:cont, socket} else socket = diff --git a/apps/gamend_web/lib/gamend_web/webrtc.ex b/apps/gamend_web/lib/gamend_web/webrtc.ex new file mode 100644 index 000000000..ed09db6b7 --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/webrtc.ex @@ -0,0 +1,70 @@ +defmodule GamendWeb.WebRTC do + @moduledoc """ + The ICE servers the server's own WebRTC peer (`GamendWeb.WebRTCPeer`) uses. + + STUN finds the server's public address; TURN relays traffic when no direct + path exists, which a server behind NAT or a UDP-hostile network needs. Both + are settings, so a deployment adds a TURN relay with env vars alone. + + A host that sets `config :gamend_web, :webrtc, ice_servers: [...]` keeps full + control: that list is used as given and these settings are ignored. + """ + + use Gamend.Settings.Provider, + app: :gamend_web, + group: :webrtc, + label: "WebRTC" + + setting(:stun_urls, :list, + default: ["stun:stun.l.google.com:19302"], + doc: "Comma-separated STUN server URLs. Empty uses none." + ) + + setting(:turn_urls, :list, + default: [], + doc: + "Comma-separated TURN server URLs (turn:host:3478, turns:host:5349). Empty uses none. " <> + "Only needed when the server itself is behind NAT or UDP is filtered." + ) + + # Complete-or-empty: one without the other warns in prod. + setting(:turn_username, :string, + required: :warn, + with: [:turn_credential], + doc: "Username for the TURN servers." + ) + + setting(:turn_credential, :string, + secret: true, + required: :warn, + with: [:turn_username], + doc: "Credential for the TURN servers." + ) + + @doc "The ICE servers for a new peer connection, in `ExWebRTC` shape." + @spec ice_servers() :: [map()] + def ice_servers do + case Keyword.fetch(Application.get_env(:gamend_web, :webrtc, []), :ice_servers) do + {:ok, servers} -> servers + :error -> declared_servers() + end + end + + defp declared_servers do + stun = Gamend.Settings.get(__MODULE__, :stun_urls) || [] + turn = Gamend.Settings.get(__MODULE__, :turn_urls) || [] + + stun_servers = if stun == [], do: [], else: [%{urls: stun}] + turn_servers = if turn == [], do: [], else: [turn_server(turn)] + stun_servers ++ turn_servers + end + + defp turn_server(urls) do + %{ + urls: urls, + username: Gamend.Settings.get(__MODULE__, :turn_username), + credential: Gamend.Settings.get(__MODULE__, :turn_credential) + } + |> Map.reject(fn {_key, value} -> is_nil(value) end) + end +end diff --git a/apps/gamend_web/mix.lock b/apps/gamend_web/mix.lock index b12798e5b..63be6abf9 100644 --- a/apps/gamend_web/mix.lock +++ b/apps/gamend_web/mix.lock @@ -22,7 +22,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.24.1", "26665565f075aaf5f83a76a409ffdef4d2f0a2f3c6840bbf0995993fbea2ad32", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "681ca576c74a94944b962eeb7e0cf19aaea517decafd3213afb403ac8f4cd2e3"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.25.0", "309898d694b17a8ca8cd88d648d3a7fe8479b04ea6831a18d97d56ed3541031b", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7da65c7af38dccf228320db32f93ae49650b0afdd850a09fd2fb191554b3faf5"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, @@ -42,7 +42,7 @@ "ex_turn": {:hex, :ex_turn, "0.2.3", "7d10ce9c784c4a01dfb9adf42d94e52000db014b4557287067cd344b2939e0b6", [:mix], [{:ex_stun, "~> 0.2.1", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "f94874c6d240a162ba610deca0e3a42a6dbc2721c37310ec1ab6063ac1e677c0"}, "ex_webrtc": {:hex, :ex_webrtc, "0.17.0", "53586bed51d165cf96e1c496519fe768e95516618d244f22d4db7f03990cab08", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.18.1", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.16.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.2", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.1", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.3", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.1", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.2", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "a1efcc34e6903d1e9a37ae59312beab3ce258a6c30dccd5973a85ed8b545ee98"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, - "exqlite": {:hex, :exqlite, "0.40.0", "d63cb394a588ecbe73f19c56fbbcf397bf6488b5872fcddc9383fb375cf6131d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f83350f2d29a38be1fd38f39081dd36f41fc51bbacf0f4c2927d01308ae331d0"}, + "exqlite": {:hex, :exqlite, "0.41.0", "f7b6d9730d19efd8a2c9d4624172e82d4f98f2094743429c111972994967b4e2", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a7e9b6bed529ab72aa07ed2a925ac109c27e6877a7a8af252361c396a4192855"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, @@ -52,28 +52,28 @@ "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, - "h2": {:hex, :h2, "0.12.0", "f393539ee2728f8118fb2024b6d5f3e2c45e40ceb31b18b4e9bf5e50d028f80f", [:rebar3], [], "hexpm", "beaafc93c54cdc5d623247334d3970cdf4bc66b6b8b296b74ba1d7c7513c3dfc"}, - "hackney": {:hex, :hackney, "4.7.4", "8fe2ddaa3ca27de99d68e682d72b66d07d2331da680f77c8000580a0122c69e6", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.5", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "d07d7e1358353ab6cc75132f058c155287f3e013d43f709fbb79d79eeab98195"}, + "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, + "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "httpoison": {:hex, :httpoison, "3.0.0", "8566a933bb9175236d1ec335978445b67cd1f5b5d3ead6ca4b80be469d41f5d9", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "9130197b7658901c493d6fcfb842fb9676300fa8a6c8ed058c8889cf1a77f3c2"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"}, - "lumis": {:hex, :lumis, "0.8.0", "981c61af9f79f30ba2fed48dd5d0b2a1a021e5555e4150442be961978e56513f", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d5b71a5b082f32fc1d007d02f03922db95b9caec335b03a1985edb726c59f63e"}, + "lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"}, + "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, - "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, - "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, + "mdex": {:hex, :mdex, "0.14.0", "6bb28b49682465f802e9a129db4319e48a34edf9626e8186c1d5ea29de3f2a81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.9", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.9", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "60e591041b7f5811004cc275e8d3ae1dffb6958159812402427264b444de5080"}, + "mdex_native": {:hex, :mdex_native, "0.2.9", "898cc5514d556c4d46fa43035d6fc925116413bb3821c5a71d4bd42f87a90b85", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "7470e6051b6193c620be02acbc12f1acd758250a60c13695ed36ebda512267ac"}, "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.2.4", "4aa7d91255d0f325d2350a8c655667d38a3995ba073eef4907d467c399f001e3", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "81c12cbe2c8bc5f547c6a8a11a71e2f55995a312c202bbc9b3ddc60c39818b91"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "mmdb2_decoder": {:hex, :mmdb2_decoder, "3.0.1", "78e3aedde88035c6873ada5ceaf41b7f15a6259ed034e0eaca72ccfa937798f0", [:mix], [], "hexpm", "316af0f388fac824782d944f54efe78e7c9691bbbdb0afd5cccdd0510adf559d"}, "nebulex": {:hex, :nebulex, "3.0.4", "b55f73e5cd455a57a4dd25df9099806b9bae2384653a76a2fa2fa3184b3abb57", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "446afc6d3f701ba991f1fb0eee36c600f888530e52f30f80b308480ad65faab6"}, "nebulex_distributed": {:hex, :nebulex_distributed, "3.2.3", "d45e28ee7d88d5a49dd4ee71aa7c2d0be3337b53875a78245bcb021bd46d53e1", [:mix], [{:ex_hash_ring, "~> 6.0 or ~> 7.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:nebulex, "~> 3.0", [hex: :nebulex, repo: "hexpm", optional: false]}, {:nebulex_local, "~> 3.0", [hex: :nebulex_local, repo: "hexpm", optional: false]}, {:nebulex_streams, "~> 0.2", [hex: :nebulex_streams, repo: "hexpm", optional: false]}, {:partitioned_buffer, "~> 0.4", [hex: :partitioned_buffer, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "67a353fb5d67506767667d816d9f8a46a813549ba74e74bb0a1a0d83a078abff"}, @@ -99,7 +99,7 @@ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "pigeon": {:hex, :pigeon, "2.1.0", "993a253a1c9cddefc81bc252943a3eff35a1a8f36322eff0b5087fb16588dbec", [:mix], [{:goth, "~> 1.4.3", [hex: :goth, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:joken, "~> 2.1", [hex: :joken, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "710a7b8c896848a2bddd3e23018e54e12d6b577fa0d233fb638d5fbfa3406c89"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, @@ -108,9 +108,9 @@ "prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, "qex": {:hex, :qex, "0.5.2", "a0c861a2de2380314c23ef592349824ca9016c5845380667ff1d9a22a8796f9b", [:mix], [], "hexpm", "6fb81bf3ae354a9abb471b9561538ea3e8540125d803b00f45cbccff52f00496"}, - "quic": {:hex, :quic, "1.8.2", "c315176d2c4fad0725e52f2a8033b96d01c4fecfc7e6a7333615ff10041142a5", [:rebar3], [], "hexpm", "274d2f41ee9c00d8d6415248df9fb5637381fe69b5387771dc37e91038a65479"}, + "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, - "redix": {:hex, :redix, "1.9.1", "99473ccc538b97eb169beea133e788c236850e84ec3bcc785ef414ffca9fb2e2", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b873953d06f893f88bb92a515ff9f9c0b65662576de27fb810e76cff3609ebab"}, + "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, @@ -133,6 +133,6 @@ "uri_query": {:hex, :uri_query, "0.2.0", "0f5e0f7ea6d9e6a7fb4929a81df9ecd756e3c71bdee5c9bc14e57d90069a82f7", [:mix], [], "hexpm", "e99f50a6af7c6643dff948db152a6a420bfe446aaec7f0924cfcdb710c175e63"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, - "webtransport": {:hex, :webtransport, "0.4.5", "0e387202bbe707389fe81373ef8c56faa9d5aa321bb4800fa4765ee7c1399785", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 1.8.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "bcb512239e48e551d5bd5c667312a9a7de4f29b89d84b1d33c5af44e1f3f730d"}, + "webtransport": {:hex, :webtransport, "0.4.7", "8e0abd5875daab05c7020b8fc0c3b318fe93aa9329302fe5ecda2a0f953cf688", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "9d2fcbfc561a172cf6f2eef00b89fc89774c039ce850414356964c147707f85e"}, "zarex": {:hex, :zarex, "1.0.6", "f657ed1187e6e90472e24c92b1fd5bf3f846e74bd240bd77276c13f336a8d168", [:mix], [], "hexpm", "b628a9b0bc312f278af2c288078c31fd4757224b82d768e91bcf3bedbe3a50e7"}, } diff --git a/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po index 85c7b8ce9..91c8aae23 100644 --- a/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ar/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "شراء" msgid "Cancel renewal" msgstr "إلغاء التجديد" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "إدارة الفوترة" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "المنتج" msgid "Provider" msgstr "المزوّد" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "المشتريات" diff --git a/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po index 116e912ba..14004090b 100644 --- a/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/bg/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Купи" msgid "Cancel renewal" msgstr "Откажи подновяването" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Управление на плащанията" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Продукт" msgid "Provider" msgstr "Доставчик" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Покупки" diff --git a/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po index 263943fd2..18ca54d7e 100644 --- a/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/cs/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Koupit" msgid "Cancel renewal" msgstr "Zrušit obnovení" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Správa plateb" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Poskytovatel" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Nákupy" diff --git a/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po index 66951669d..ebb8395e3 100644 --- a/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/da/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Køb" msgid "Cancel renewal" msgstr "Opsig fornyelse" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Administrer betaling" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Udbyder" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Køb" diff --git a/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po index eb21d9baa..cd85f7d08 100644 --- a/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/de/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Kaufen" msgid "Cancel renewal" msgstr "Verlängerung kündigen" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Zahlungen verwalten" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Anbieter" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Käufe" diff --git a/apps/gamend_web/priv/gettext/default.pot b/apps/gamend_web/priv/gettext/default.pot index dcb861ef5..51f7aeecc 100644 --- a/apps/gamend_web/priv/gettext/default.pot +++ b/apps/gamend_web/priv/gettext/default.pot @@ -897,6 +897,11 @@ msgstr "" msgid "Cancel renewal" msgstr "" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1091,7 +1096,7 @@ msgstr "" msgid "Provider" msgstr "" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "" diff --git a/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po index 5ebfc48cb..84c50442a 100644 --- a/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/el/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Αγορά" msgid "Cancel renewal" msgstr "Ακύρωση ανανέωσης" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Διαχείριση πληρωμών" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Προϊόν" msgid "Provider" msgstr "Πάροχος" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Αγορές" diff --git a/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po index 91800b657..580a03c4e 100644 --- a/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/en/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Buy" msgid "Cancel renewal" msgstr "Cancel renewal" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1088,7 +1093,7 @@ msgstr "Product" msgid "Provider" msgstr "Provider" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Purchases" diff --git a/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po index b38a7ec40..8807a4fc7 100644 --- a/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/es/LC_MESSAGES/default.po @@ -885,6 +885,11 @@ msgstr "Comprar" msgid "Cancel renewal" msgstr "Cancelar renovación" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gestionar pagos" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1079,7 +1084,7 @@ msgstr "Producto" msgid "Provider" msgstr "Proveedor" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Compras" diff --git a/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po index 2fe0b630c..26bab8798 100644 --- a/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/fi/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Osta" msgid "Cancel renewal" msgstr "Peruuta uusiminen" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Hallitse laskutusta" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Tuote" msgid "Provider" msgstr "Palveluntarjoaja" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Ostokset" diff --git a/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po index f9f3c65c2..3eeb16c1d 100644 --- a/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/fr/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Acheter" msgid "Cancel renewal" msgstr "Annuler le renouvellement" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gérer la facturation" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produit" msgid "Provider" msgstr "Prestataire" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Achats" diff --git a/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po index 18b93808f..80f4b151c 100644 --- a/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/hu/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Vásárlás" msgid "Cancel renewal" msgstr "Megújítás lemondása" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Számlázás kezelése" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1087,7 +1092,7 @@ msgstr "Termék" msgid "Provider" msgstr "Szolgáltató" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Vásárlások" diff --git a/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po index eb984e712..cead0b512 100644 --- a/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/id/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Beli" msgid "Cancel renewal" msgstr "Batalkan perpanjangan" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Kelola tagihan" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produk" msgid "Provider" msgstr "Penyedia" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Pembelian" diff --git a/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po index 641ecc38f..40d28a17d 100644 --- a/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/it/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Acquista" msgid "Cancel renewal" msgstr "Annulla rinnovo" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gestisci pagamenti" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1087,7 +1092,7 @@ msgstr "Prodotto" msgid "Provider" msgstr "Provider" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Acquisti" diff --git a/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po index e9a6f125d..3c6bd734a 100644 --- a/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ja/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "購入" msgid "Cancel renewal" msgstr "更新を解約" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "支払いの管理" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "商品" msgid "Provider" msgstr "プロバイダー" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "購入" diff --git a/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po index 926d451eb..3072e2c4d 100644 --- a/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ko/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "구매" msgid "Cancel renewal" msgstr "갱신 해지" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "결제 관리" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "상품" msgid "Provider" msgstr "제공업체" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "구매" diff --git a/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po index 2fdf70078..65ab1c41b 100644 --- a/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/nl/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Kopen" msgid "Cancel renewal" msgstr "Verlenging opzeggen" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Betalingen beheren" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Product" msgid "Provider" msgstr "Provider" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Aankopen" diff --git a/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po index bbf9f5fc1..fb3a0c17b 100644 --- a/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/no/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Kjøp" msgid "Cancel renewal" msgstr "Avslutt fornyelse" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Administrer betaling" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Leverandør" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Kjøp" diff --git a/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po index 2b2cd9362..016d5d666 100644 --- a/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pl/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Kup" msgid "Cancel renewal" msgstr "Anuluj odnowienie" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Zarządzaj płatnościami" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Dostawca" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Zakupy" diff --git a/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po index b2444a96b..906c407a2 100644 --- a/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pt/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Comprar" msgid "Cancel renewal" msgstr "Cancelar renovação" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gerir pagamentos" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produto" msgid "Provider" msgstr "Fornecedor" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Compras" diff --git a/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po index 8afcab699..25e469ca2 100644 --- a/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/pt_BR/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Comprar" msgid "Cancel renewal" msgstr "Cancelar renovação" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gerenciar pagamentos" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produto" msgid "Provider" msgstr "Provedor" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Compras" diff --git a/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po index 0a1e83f19..1013e10ec 100644 --- a/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ro/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Cumpără" msgid "Cancel renewal" msgstr "Anulează reînnoirea" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Gestionează plățile" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produs" msgid "Provider" msgstr "Furnizor" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Achiziții" diff --git a/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po index 93542bbf2..5b367dacc 100644 --- a/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/ru/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Купить" msgid "Cancel renewal" msgstr "Отменить продление" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Управление оплатой" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Продукт" msgid "Provider" msgstr "Провайдер" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Покупки" diff --git a/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po index cbc3a5c29..6e28f3663 100644 --- a/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/sv/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Köp" msgid "Cancel renewal" msgstr "Avsluta förnyelse" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Hantera betalningar" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Produkt" msgid "Provider" msgstr "Leverantör" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Köp" diff --git a/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po index 4b05ad970..52d604eab 100644 --- a/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/th/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "ซื้อ" msgid "Cancel renewal" msgstr "ยกเลิกการต่ออายุ" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "จัดการการชำระเงิน" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "สินค้า" msgid "Provider" msgstr "ผู้ให้บริการ" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "การซื้อ" diff --git a/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po index 64bf45b20..512a27aa0 100644 --- a/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/tr/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Satın al" msgid "Cancel renewal" msgstr "Yenilemeyi iptal et" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Ödemeleri yönet" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Ürün" msgid "Provider" msgstr "Sağlayıcı" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Satın alımlar" diff --git a/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po index b9965f793..51d73add5 100644 --- a/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/uk/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Купити" msgid "Cancel renewal" msgstr "Скасувати продовження" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Керування оплатою" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Продукт" msgid "Provider" msgstr "Провайдер" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Покупки" diff --git a/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po index bed13a515..7ded4ce8d 100644 --- a/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/vi/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "Mua" msgid "Cancel renewal" msgstr "Hủy gia hạn" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "Quản lý thanh toán" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "Sản phẩm" msgid "Provider" msgstr "Nhà cung cấp" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "Giao dịch mua" diff --git a/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po index e84ed0ede..cea044b5d 100644 --- a/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/zh_CN/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "购买" msgid "Cancel renewal" msgstr "取消续订" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "管理付款" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "商品" msgid "Provider" msgstr "提供商" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "购买" diff --git a/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po b/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po index e4a0839b4..4e8abf3e0 100644 --- a/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po +++ b/apps/gamend_web/priv/gettext/zh_TW/LC_MESSAGES/default.po @@ -892,6 +892,11 @@ msgstr "購買" msgid "Cancel renewal" msgstr "取消續訂" +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 +#, elixir-autogen, elixir-format +msgid "Manage billing" +msgstr "管理付款" + #: lib/gamend_web/live/user_live/settings/payments_tab.ex:234 #, elixir-autogen, elixir-format msgid "Cancels" @@ -1086,7 +1091,7 @@ msgstr "商品" msgid "Provider" msgstr "供應商" -#: lib/gamend_web/live/user_live/settings/payments_tab.ex:44 +#: lib/gamend_web/live/user_live/settings/payments_tab.ex:47 #, elixir-autogen, elixir-format msgid "Purchases" msgstr "購買" diff --git a/apps/gamend_web/test/gamend/runtime_config_test.exs b/apps/gamend_web/test/gamend/runtime_config_test.exs index 09d3ffd35..6f026bf64 100644 --- a/apps/gamend_web/test/gamend/runtime_config_test.exs +++ b/apps/gamend_web/test/gamend/runtime_config_test.exs @@ -115,6 +115,23 @@ defmodule Gamend.RuntimeConfigTest do assert [{Gamend.Cache.L1, _}, {Gamend.Cache.L2.Partitioned, _}] = levels end + + @tag env: %{ + "GAMEND_AUTH_SECRET_KEY_BASE" => String.duplicate("a", 64), + "GAMEND_CACHE_MODE" => "multi", + "GAMEND_CACHE_MAX_ENTRIES" => "5000", + "GAMEND_CACHE_MAX_MEMORY_MB" => "64" + } + test "each node's local cache is sized by the settings", %{config: config} do + levels = config[:gamend_core][Gamend.Cache][:levels] + + assert [{Gamend.Cache.L1, l1}, {Gamend.Cache.L2.Partitioned, l2}] = levels + + for opts <- [l1, l2[:primary]] do + assert opts[:max_size] == 5_000 + assert opts[:allocated_memory] == 64_000_000 + end + end end describe "endpoint" do diff --git a/apps/gamend_web/test/gamend_web/abuse_limits_test.exs b/apps/gamend_web/test/gamend_web/abuse_limits_test.exs index 6c7ca3483..e3efe94c5 100644 --- a/apps/gamend_web/test/gamend_web/abuse_limits_test.exs +++ b/apps/gamend_web/test/gamend_web/abuse_limits_test.exs @@ -85,4 +85,84 @@ defmodule GamendWeb.AbuseLimitsTest do end) end end + + describe "IPv6 clients are limited per /64" do + setup do + previous = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) + + Application.put_env( + :gamend_web, + GamendWeb.Plugs.RateLimiter, + Keyword.merge(previous, enabled: true, auth_limit: 2, auth_window_ms: 60_000) + ) + + on_exit(fn -> + Application.put_env(:gamend_web, GamendWeb.Plugs.RateLimiter, previous) + end) + + :ok + end + + test "ip_key/1: IPv4 as itself, IPv6 by /64, IPv4-mapped as the IPv4" do + assert GamendWeb.RateLimit.ip_key({203, 0, 113, 7}) == "203.0.113.7" + assert GamendWeb.RateLimit.ip_key({0x2001, 0xDB8, 1, 2, 3, 4, 5, 6}) == "2001:db8:1:2::/64" + assert GamendWeb.RateLimit.ip_key({0, 0, 0, 0, 0, 0xFFFF, 0xCB00, 0x7107}) == "203.0.113.7" + assert GamendWeb.RateLimit.ip_key("2001:db8:1:2:ffff::1") == "2001:db8:1:2::/64" + assert GamendWeb.RateLimit.ip_key("unknown") == "unknown" + end + + test "addresses in one /64 share the auth bucket; another /64 does not" do + login = fn ip -> + %{build_conn() | remote_ip: ip} + |> post("/api/v1/login", %{email: "nobody@example.com", password: "wrong password"}) + end + + refute login.({0x2001, 0xDB8, 0xAB, 1, 0, 0, 0, 1}).status == 429 + refute login.({0x2001, 0xDB8, 0xAB, 1, 0xA, 0xB, 0xC, 0xD}).status == 429 + assert login.({0x2001, 0xDB8, 0xAB, 1, 0xF, 0xF, 0xF, 0xF}).status == 429 + + refute login.({0x2001, 0xDB8, 0xAB, 2, 0, 0, 0, 1}).status == 429 + end + end + + describe "the HTTP rate limiter" do + setup do + previous = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) + + Application.put_env( + :gamend_web, + GamendWeb.Plugs.RateLimiter, + Keyword.merge(previous, enabled: true, auth_limit: 1, auth_window_ms: 60_000) + ) + + on_exit(fn -> + Application.put_env(:gamend_web, GamendWeb.Plugs.RateLimiter, previous) + end) + + :ok + end + + test "refuses a request over its limit before reading the body" do + login = fn ip, body -> + %{build_conn() | remote_ip: ip} + |> put_req_header("content-type", "application/json") + |> post("/api/v1/login", body) + end + + ip = {198, 51, 100, 21} + _ = login.(ip, ~s({"email": "a@example.com", "password": "x"})) + + # Malformed JSON: had the body been parsed first, this would be a 400. + assert login.(ip, "{not json").status == 429 + end + + test "counts a locale-prefixed browser login against the auth bucket" do + login = fn -> + post(%{build_conn() | remote_ip: {198, 51, 100, 22}}, "/ro/users/log_in", %{}) + end + + refute login.().status == 429 + assert login.().status == 429 + end + end end diff --git a/apps/gamend_web/test/gamend_web/account_protection_test.exs b/apps/gamend_web/test/gamend_web/account_protection_test.exs new file mode 100644 index 000000000..bec825f35 --- /dev/null +++ b/apps/gamend_web/test/gamend_web/account_protection_test.exs @@ -0,0 +1,207 @@ +defmodule GamendWeb.AccountProtectionTest do + @moduledoc """ + Per-account lockout after failed passwords, and the grace period before a + deleted account is gone, as the API and the browser see them. + """ + # Settings are global Application config. + use GamendWeb.ConnCase, async: false + + import Gamend.AccountsFixtures + + alias Gamend.Accounts + alias Gamend.Accounts.User + alias Gamend.Repo + alias Gamend.SettingsHelpers + alias GamendWeb.Auth.Guardian + + defp put_setting(key, value) do + SettingsHelpers.put(:gamend_core, Accounts, key, value) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, key) end) + end + + defp api_login(email, password) do + post(build_conn(), "/api/v1/login", %{email: email, password: password}) + end + + describe "lockout" do + setup do + put_setting(:lockout_attempts, 2) + %{user: set_password(user_fixture())} + end + + test "the API answers 429 account_locked with Retry-After, even for the right password", + %{user: user} do + assert json_response(api_login(user.email, "wrong password!"), 401) + + locked = api_login(user.email, "wrong password!") + assert %{"error" => "account_locked"} = json_response(locked, 429) + + conn = api_login(user.email, valid_user_password()) + assert %{"error" => "account_locked"} = json_response(conn, 429) + assert [retry_after] = get_resp_header(conn, "retry-after") + assert String.to_integer(retry_after) in 1..(15 * 60) + end + + test "the browser says so, and an emailed link still signs in", %{conn: conn, user: user} do + for _ <- 1..2 do + post(build_conn(), ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => "wrong password!"} + }) + end + + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert redirected_to(conn) == ~p"/users/log_in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Too many failed attempts" + refute get_session(conn, :user_token) + + token = extract_user_token(&Accounts.deliver_login_instructions(user, &1)) + conn = post(build_conn(), ~p"/users/log_in", %{"user" => %{"token" => token}}) + assert get_session(conn, :user_token) + end + end + + describe "deletion grace period" do + setup do + put_setting(:deletion_grace_days, 30) + %{user: set_password(user_fixture())} + end + + test "DELETE /me schedules the account and signs it out", %{user: user} do + {:ok, token, _} = Guardian.encode_and_sign(user) + + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token) + |> delete("/api/v1/me", %{current_password: valid_user_password()}) + + assert json_response(conn, 200) == %{"ok" => true} + assert %User{deletion_scheduled_at: %DateTime{}} = Repo.get(User, user.id) + + stale = + build_conn() + |> put_req_header("authorization", "Bearer " <> token) + |> get("/api/v1/me") + + assert json_response(stale, 401) + end + + test "API sign-ins are refused until then", %{user: user} do + {:ok, {:scheduled, _, _}} = Accounts.request_deletion(user) + + conn = api_login(user.email, valid_user_password()) + assert %{"error" => "deletion_scheduled"} = json_response(conn, 403) + assert Accounts.deletion_scheduled?(Accounts.get_user!(user.id)) + end + + test "a device sign-in does not undo it" do + device_id = "device:#{System.unique_integer([:positive])}" + conn = post(build_conn(), "/api/v1/login/device", %{device_id: device_id}) + %{"data" => %{"user_id" => id}} = json_response(conn, 200) + + {:ok, {:scheduled, _, _}} = Accounts.request_deletion(Accounts.get_user!(id)) + + conn = post(build_conn(), "/api/v1/login/device", %{device_id: device_id}) + assert %{"error" => "deletion_scheduled"} = json_response(conn, 403) + end + + test "signing in on the website keeps the account", %{conn: conn, user: user} do + {:ok, {:scheduled, _, _}} = Accounts.request_deletion(user) + + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "will not be deleted" + refute Accounts.deletion_scheduled?(Accounts.get_user!(user.id)) + end + end + + describe "stored objects on a private S3 bucket" do + setup do + storage = Application.get_env(:gamend_core, Gamend.Storage, []) + s3 = Application.get_env(:gamend_core, Gamend.Storage.S3, []) + + Application.put_env(:gamend_core, Gamend.Storage, Keyword.put(storage, :adapter, :s3)) + + Application.put_env( + :gamend_core, + Gamend.Storage.S3, + Keyword.merge(s3, + bucket: "avatars-test", + access_key_id: "AKIDEXAMPLE", + secret_access_key: "secret", + region: "us-east-1" + ) + ) + + on_exit(fn -> + Application.put_env(:gamend_core, Gamend.Storage, storage) + Application.put_env(:gamend_core, Gamend.Storage.S3, s3) + end) + end + + test "/storage/ redirects to a freshly signed link", %{conn: conn} do + conn = get(conn, "/storage/avatars/some-user/abc.png") + + assert location = redirected_to(conn, 302) + assert location =~ "avatars-test" + assert location =~ "avatars/some-user/abc.png" + assert location =~ "X-Amz-Expires=3600" + assert get_resp_header(conn, "cache-control") == ["public, max-age=1800"] + end + + test "keys outside the public prefixes are still not served", %{conn: conn} do + assert json_response(get(conn, "/storage/backups/db.sql"), 404) + end + end + + test "the request body limit is a setting" do + SettingsHelpers.put(:gamend_web, GamendWeb.Http, :max_body_bytes, 64) + on_exit(fn -> SettingsHelpers.delete(:gamend_web, GamendWeb.Http, :max_body_bytes) end) + + body = Jason.encode!(%{email: String.duplicate("a", 100) <> "@example.com", password: "x"}) + + assert_error_sent 413, fn -> + build_conn() + |> put_req_header("content-type", "application/json") + |> post("/api/v1/login", body) + end + end + + describe "WebRTC ICE servers" do + setup do + webrtc = Application.get_env(:gamend_web, :webrtc, []) + Application.put_env(:gamend_web, :webrtc, Keyword.delete(webrtc, :ice_servers)) + on_exit(fn -> Application.put_env(:gamend_web, :webrtc, webrtc) end) + end + + test "STUN by default, TURN from the settings" do + assert GamendWeb.WebRTC.ice_servers() == [%{urls: ["stun:stun.l.google.com:19302"]}] + + for {key, value} <- [ + turn_urls: ["turn:relay.example.com:3478"], + turn_username: "game", + turn_credential: "s3cret" + ] do + SettingsHelpers.put(:gamend_web, GamendWeb.WebRTC, key, value) + on_exit(fn -> SettingsHelpers.delete(:gamend_web, GamendWeb.WebRTC, key) end) + end + + assert [ + %{urls: ["stun:stun.l.google.com:19302"]}, + %{urls: ["turn:relay.example.com:3478"], username: "game", credential: "s3cret"} + ] = GamendWeb.WebRTC.ice_servers() + end + + test "an ice_servers list in the host config wins" do + Application.put_env(:gamend_web, :webrtc, ice_servers: [%{urls: "stun:own.example"}]) + assert GamendWeb.WebRTC.ice_servers() == [%{urls: "stun:own.example"}] + end + end +end diff --git a/apps/gamend_web/test/gamend_web/auth/api_token_auth_test.exs b/apps/gamend_web/test/gamend_web/auth/api_token_auth_test.exs new file mode 100644 index 000000000..5a4517bd1 --- /dev/null +++ b/apps/gamend_web/test/gamend_web/auth/api_token_auth_test.exs @@ -0,0 +1,78 @@ +defmodule GamendWeb.Auth.ApiTokenAuthTest do + @moduledoc """ + A personal API token authenticates any route an access token does, through + both pipelines, and stops the moment it is revoked, expired or superseded. + """ + use GamendWeb.ConnCase, async: true + + import Ecto.Query + + alias Gamend.Accounts + alias Gamend.Accounts.ApiToken + alias Gamend.Accounts.ApiTokens + alias Gamend.AccountsFixtures + alias Gamend.Repo + alias GamendWeb.Auth.Guardian + + setup do + user = AccountsFixtures.user_fixture() + {:ok, token, row} = ApiTokens.create(user, %{"name" => "ci"}) + %{user: user, token: token, row: row} + end + + defp me(conn, token) do + conn + |> put_req_header("authorization", "Bearer " <> token) + |> get("/api/v1/me") + end + + test "authenticates as its owner", %{conn: conn, user: user, token: token} do + assert %{"data" => %{"id" => id}} = conn |> me(token) |> json_response(200) + assert id == user.id + end + + test "the scheme is case-insensitive, as it is for a JWT", %{conn: conn, token: token} do + conn = + conn + |> put_req_header("authorization", "bearer " <> token) + |> get("/api/v1/me") + + assert json_response(conn, 200) + end + + test "records its use", %{conn: conn, token: token, row: row} do + assert conn |> me(token) |> json_response(200) + + assert Repo.get!(ApiToken, row.id).last_used_at + end + + test "an unknown token is a 401 with the invalid_token code", %{conn: conn} do + assert %{"error" => "invalid_token"} = + conn |> me("gamend_pat_notarealtoken") |> json_response(401) + end + + test "a revoked token is a 401", %{conn: conn, user: user, token: token, row: row} do + {:ok, _} = ApiTokens.revoke(user.id, row.id) + + assert conn |> me(token) |> json_response(401) + end + + test "an expired token is a 401", %{conn: conn, token: token, row: row} do + past = DateTime.utc_now() |> DateTime.add(-1, :minute) |> DateTime.truncate(:second) + Repo.update_all(from(t in ApiToken, where: t.id == ^row.id), set: [expires_at: past]) + + assert conn |> me(token) |> json_response(401) + end + + test "signing out everywhere retires it", %{conn: conn, user: user, token: token} do + {:ok, _} = Accounts.revoke_all_tokens(user) + + assert conn |> me(token) |> json_response(401) + end + + test "a JWT still works beside it", %{conn: conn, user: user} do + {:ok, jwt, _claims} = Guardian.encode_and_sign(user) + + assert conn |> me(jwt) |> json_response(200) + end +end diff --git a/apps/gamend_web/test/gamend_web/auth/guardian_test.exs b/apps/gamend_web/test/gamend_web/auth/guardian_test.exs index da33cabd1..cd3830bb4 100644 --- a/apps/gamend_web/test/gamend_web/auth/guardian_test.exs +++ b/apps/gamend_web/test/gamend_web/auth/guardian_test.exs @@ -62,12 +62,13 @@ defmodule GamendWeb.Auth.GuardianTest do end describe "token expiration" do - test "tokens have a default TTL" do + test "an access token lasts 15 minutes and a refresh token 30 days by default" do user = AccountsFixtures.user_fixture() - {:ok, _token, claims} = Guardian.encode_and_sign(user) + {:ok, _token, access} = Guardian.encode_and_sign(user) + {:ok, _token, refresh} = Guardian.encode_and_sign(user, %{}, token_type: "refresh") - assert Map.has_key?(claims, "exp") - assert claims["exp"] > System.system_time(:second) + assert access["exp"] - access["iat"] == 15 * 60 + assert refresh["exp"] - refresh["iat"] == 30 * 86_400 end end end diff --git a/apps/gamend_web/test/gamend_web/channels/signaling_channel_test.exs b/apps/gamend_web/test/gamend_web/channels/signaling_channel_test.exs index 06df1ff3a..c73ed6ba8 100644 --- a/apps/gamend_web/test/gamend_web/channels/signaling_channel_test.exs +++ b/apps/gamend_web/test/gamend_web/channels/signaling_channel_test.exs @@ -83,4 +83,29 @@ defmodule GamendWeb.SignalingChannelTest do assert pushed_ids("user_left") == [peer.id] end + + test "ICE relayed over signaling is limited by its own settings", %{lobby: lobby, host: host} do + limiter = Application.get_env(:gamend_web, GamendWeb.Plugs.RateLimiter, []) + + Application.put_env( + :gamend_web, + GamendWeb.Plugs.RateLimiter, + Keyword.merge(limiter, enabled: true, signaling_ice_limit: 1) + ) + + on_exit(fn -> Application.put_env(:gamend_web, GamendWeb.Plugs.RateLimiter, limiter) end) + + {:ok, _reply, socket} = join_signaling(host, lobby.id) + + ice = %{ + "target" => Ecto.UUID.generate(), + "candidate" => "candidate:1 1 udp 1 1.2.3.4 5 typ host" + } + + ref = push(socket, "ice", ice) + refute_reply ref, :error, %{error: "ice_rate_limited"} + + ref = push(socket, "ice", ice) + assert_reply ref, :error, %{error: "ice_rate_limited"} + end end diff --git a/apps/gamend_web/test/gamend_web/controllers/api/v1/hook_controller_test.exs b/apps/gamend_web/test/gamend_web/controllers/api/v1/hook_controller_test.exs index c647b7d9b..d15f12e9d 100644 --- a/apps/gamend_web/test/gamend_web/controllers/api/v1/hook_controller_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/api/v1/hook_controller_test.exs @@ -128,10 +128,15 @@ defmodule GamendWeb.Api.V1.HookControllerTest do old_request_threshold = Application.get_env(:gamend_web, :slow_request_threshold_ms, :unset) - old_hook_threshold = Application.get_env(:gamend_core, :slow_hook_threshold_ms, :unset) + old_hook_settings = Application.get_env(:gamend_core, Gamend.Hooks.PluginManager, []) Application.put_env(:gamend_web, :slow_request_threshold_ms, -1.0) - Application.put_env(:gamend_core, :slow_hook_threshold_ms, -1.0) + + Application.put_env( + :gamend_core, + Gamend.Hooks.PluginManager, + Keyword.put(old_hook_settings, :slow_threshold_ms, -1) + ) try do body2 = %{"plugin" => plugin_name, "fn" => "greet", "args" => []} @@ -157,7 +162,7 @@ defmodule GamendWeb.Api.V1.HookControllerTest do refute log =~ "secret-value" after restore_env(:slow_request_threshold_ms, old_request_threshold) - restore_core_env(:slow_hook_threshold_ms, old_hook_threshold) + Application.put_env(:gamend_core, Gamend.Hooks.PluginManager, old_hook_settings) end body3 = %{"plugin" => plugin_name, "fn" => "boom", "args" => []} @@ -320,6 +325,4 @@ defmodule GamendWeb.Api.V1.HookControllerTest do defp restore_env(key, :unset), do: Application.delete_env(:gamend_web, key) defp restore_env(key, value), do: Application.put_env(:gamend_web, key, value) - defp restore_core_env(key, :unset), do: Application.delete_env(:gamend_core, key) - defp restore_core_env(key, value), do: Application.put_env(:gamend_core, key, value) end diff --git a/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs b/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs index fbf80a697..69247fa23 100644 --- a/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/api/v1/session_controller_test.exs @@ -1,8 +1,10 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do use GamendWeb.ConnCase, async: false + use Oban.Testing, repo: Gamend.Repo alias Gamend.Accounts.User alias Gamend.Repo + alias GamendWeb.Auth.Guardian @valid_email "testuser@example.com" @valid_password "hello world!" @@ -218,6 +220,52 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do end end + describe "token lifetimes" do + setup do + accounts = Application.get_env(:gamend_core, Gamend.Accounts, []) + on_exit(fn -> Application.put_env(:gamend_core, Gamend.Accounts, accounts) end) + :ok + end + + test "login and refresh follow the TTL settings", %{conn: conn} do + put_accounts_setting(:access_token_ttl_minutes, 2) + put_accounts_setting(:refresh_token_ttl_days, 7) + + login = post(conn, "/api/v1/login", %{email: @valid_email, password: @valid_password}) + + assert %{"data" => %{"access_token" => access, "refresh_token" => refresh} = data} = + json_response(login, 200) + + assert data["expires_in"] == 120 + assert lifetime(access) == 120 + assert lifetime(refresh) == 7 * 86_400 + + refreshed = post(build_conn(), "/api/v1/refresh", %{refresh_token: refresh}) + + assert %{"data" => %{"access_token" => new_access, "expires_in" => 120}} = + json_response(refreshed, 200) + + assert lifetime(new_access) == 120 + end + + test "a TTL below one counts as one", %{conn: conn} do + put_accounts_setting(:access_token_ttl_minutes, 0) + put_accounts_setting(:refresh_token_ttl_days, -3) + + login = post(conn, "/api/v1/login", %{email: @valid_email, password: @valid_password}) + + assert %{"data" => %{"refresh_token" => refresh, "expires_in" => 60}} = + json_response(login, 200) + + assert lifetime(refresh) == 86_400 + end + end + + defp lifetime(token) do + {:ok, %{"exp" => exp, "iat" => iat}} = Guardian.decode_and_verify(token) + exp - iat + end + defp put_accounts_setting(key, value) do existing = Application.get_env(:gamend_core, Gamend.Accounts, []) Application.put_env(:gamend_core, Gamend.Accounts, Keyword.put(existing, key, value)) @@ -227,6 +275,13 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do def deliver_confirmation_instructions(_user, _url), do: {:error, :smtp_failed} end + defmodule RefuseRegisterHooks do + use Gamend.TestSupport.NoopHooks + + @impl true + def before_user_register(_user, _attrs), do: {:error, "closed beta"} + end + describe "POST /api/v1/register" do setup do accounts = Application.get_env(:gamend_core, Gamend.Accounts, []) @@ -252,16 +307,18 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do assert json_response(login, 200)["data"]["user_id"] == user_id end - test "sends the confirmation email, as browser sign-up does", %{conn: conn} do + test "queues the confirmation email, as browser sign-up does", %{conn: conn} do post(conn, "/api/v1/register", %{email: "mailed@example.com", password: @valid_password}) + assert %{success: 1} = Oban.drain_queue(queue: :mailers) + Swoosh.TestAssertions.assert_email_sent( to: "mailed@example.com", subject: "Confirmation instructions" ) end - test "keeps no account when its email cannot be sent", %{conn: conn} do + test "answers without waiting for the email; a failed send keeps the account", %{conn: conn} do notifier = Application.get_env(:gamend_web, :user_notifier) Application.put_env(:gamend_web, :user_notifier, __MODULE__.FailNotifier) @@ -271,11 +328,27 @@ defmodule GamendWeb.Api.V1.SessionControllerTest do else: Application.delete_env(:gamend_web, :user_notifier) end) - failed = + created = post(conn, "/api/v1/register", %{email: "bounced@example.com", password: @valid_password}) - assert json_response(failed, 503)["error"] == "email_delivery_failed" - refute Repo.get_by(User, email: "bounced@example.com") + assert json_response(created, 201) + assert [job] = all_enqueued(worker: Gamend.Accounts.ConfirmationMailer) + assert {:error, :smtp_failed} = perform_job(Gamend.Accounts.ConfirmationMailer, job.args) + assert Repo.get_by(User, email: "bounced@example.com") + end + + test "a plugin that refuses the sign-up is 403 registration_refused", %{conn: conn} do + hooks = Application.get_env(:gamend_core, :hooks_module) + Application.put_env(:gamend_core, :hooks_module, __MODULE__.RefuseRegisterHooks) + on_exit(fn -> Application.put_env(:gamend_core, :hooks_module, hooks) end) + + refused = + post(conn, "/api/v1/register", %{email: "beta@example.com", password: @valid_password}) + + assert %{"error" => "registration_refused", "message" => "closed beta"} = + json_response(refused, 403) + + refute Repo.get_by(User, email: "beta@example.com") end test "keeps a username the caller picked", %{conn: conn} do diff --git a/apps/gamend_web/test/gamend_web/live/configurable_pages_test.exs b/apps/gamend_web/test/gamend_web/live/configurable_pages_test.exs new file mode 100644 index 000000000..0d72a9061 --- /dev/null +++ b/apps/gamend_web/test/gamend_web/live/configurable_pages_test.exs @@ -0,0 +1,110 @@ +defmodule GamendWeb.ConfigurablePagesTest do + @moduledoc """ + The pages that follow the new `auth.*` settings: deleting an account with a + grace period, API token lifetimes under a cap, and the admin controls for a + scheduled deletion and a locked password. + """ + # Settings are global Application config. + use GamendWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Gamend.Accounts + alias Gamend.Accounts.User + alias Gamend.AccountsFixtures + alias Gamend.Repo + alias Gamend.SettingsHelpers + + defp put_setting(key, value) do + SettingsHelpers.put(:gamend_core, Accounts, key, value) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Accounts, key) end) + end + + defp admin do + {:ok, admin} = + AccountsFixtures.user_fixture() + |> User.admin_changeset(%{"is_admin" => true}) + |> Repo.update() + + admin + end + + test "deleting your account with a grace period schedules it", %{conn: conn} do + put_setting(:deletion_grace_days, 14) + user = AccountsFixtures.user_fixture() + + conn = log_in_user(conn, user) + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + assert lv |> element("#delete-account-button") |> render() =~ "deleted in 14 days" + + {:ok, conn} = + lv + |> element("#delete-account-button") + |> render_click() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "will be deleted in 14 days" + assert Accounts.deletion_scheduled?(Repo.get!(User, user.id)) + end + + test "under a cap, the API token form offers only lifetimes within it", %{conn: conn} do + put_setting(:api_token_max_days, 60) + user = AccountsFixtures.user_fixture() + + {:ok, lv, _html} = conn |> log_in_user(user) |> live(~p"/users/settings") + + lv + |> element(~s(button[phx-click="settings_tab"][phx-value-tab="api_tokens"])) + |> render_click() + + options = lv |> element("#api-token-form select") |> render() + + assert options =~ ~s(value="30") + assert options =~ ~s(value="60") + refute options =~ ~s(value="never") + refute options =~ ~s(value="90") + assert options =~ ~r/]*selected[^>]*value="60"|]*value="60"[^>]*selected/ + end + + describe "admin users page" do + test "shows a scheduled deletion and keeps the account", %{conn: conn} do + put_setting(:deletion_grace_days, 14) + player = AccountsFixtures.user_fixture() + {:ok, {:scheduled, _, _}} = Accounts.request_deletion(player) + + {:ok, lv, html} = conn |> log_in_user(admin()) |> live(~p"/admin/users") + assert html =~ "Deleting" + + render_click(lv, "edit_user", %{"id" => player.id}) + assert has_element?(lv, "#admin-user-deletion-scheduled") + + lv |> element("#admin-keep-account") |> render_click() + + refute has_element?(lv, "#admin-user-deletion-scheduled") + refute Accounts.deletion_scheduled?(Repo.get!(User, player.id)) + end + + test "shows a locked password and unlocks it", %{conn: conn} do + put_setting(:lockout_attempts, 1) + player = AccountsFixtures.user_fixture() |> AccountsFixtures.set_password() + + assert {:error, {:locked, _}} = + Accounts.authenticate_by_password(player.email, "wrong password!") + + {:ok, lv, _html} = conn |> log_in_user(admin()) |> live(~p"/admin/users") + render_click(lv, "edit_user", %{"id" => player.id}) + assert has_element?(lv, "#admin-user-login-locked") + + lv |> element("#admin-unlock-login") |> render_click() + + refute has_element?(lv, "#admin-user-login-locked") + + assert {:ok, _} = + Accounts.authenticate_by_password( + player.email, + AccountsFixtures.valid_user_password() + ) + end + end +end diff --git a/apps/gamend_web/test/gamend_web/live/user_live/registration_test.exs b/apps/gamend_web/test/gamend_web/live/user_live/registration_test.exs index 8ebd2ded0..277f55684 100644 --- a/apps/gamend_web/test/gamend_web/live/user_live/registration_test.exs +++ b/apps/gamend_web/test/gamend_web/live/user_live/registration_test.exs @@ -104,6 +104,7 @@ defmodule GamendWeb.UserLive.RegistrationDeliveryFailureTest do Sync, and kept apart from `RegistrationTest` so the rest of it stays async. """ use GamendWeb.ConnCase, async: false + use Oban.Testing, repo: Gamend.Repo import Phoenix.LiveViewTest import Gamend.AccountsFixtures @@ -125,7 +126,7 @@ defmodule GamendWeb.UserLive.RegistrationDeliveryFailureTest do :ok end - test "shows friendly error when confirmation delivery fails", %{conn: conn} do + test "sign-up does not wait on the mail server; a failed send is left to the job", %{conn: conn} do # ensure this is not the first user so email delivery is attempted _existing = user_fixture() @@ -134,10 +135,11 @@ defmodule GamendWeb.UserLive.RegistrationDeliveryFailureTest do email = unique_user_email() form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) - html = render_submit(form) + {:ok, _lv, html} = render_submit(form) |> follow_redirect(conn, ~p"/users/log_in") + assert html =~ "Account created. Check your email" - assert html =~ "Failed" - - refute Gamend.Repo.get_by(Gamend.Accounts.User, email: email) + assert [job] = all_enqueued(worker: Gamend.Accounts.ConfirmationMailer) + assert {:error, :smtp_failed} = perform_job(Gamend.Accounts.ConfirmationMailer, job.args) + assert Gamend.Repo.get_by(Gamend.Accounts.User, email: email) end end diff --git a/apps/gamend_web/test/gamend_web/live/user_live/settings_api_tokens_test.exs b/apps/gamend_web/test/gamend_web/live/user_live/settings_api_tokens_test.exs new file mode 100644 index 000000000..68e0be7ca --- /dev/null +++ b/apps/gamend_web/test/gamend_web/live/user_live/settings_api_tokens_test.exs @@ -0,0 +1,84 @@ +defmodule GamendWeb.UserLive.SettingsApiTokensTest do + use GamendWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Gamend.Accounts.ApiTokens + alias Gamend.AccountsFixtures + + defp open_tab(conn, user) do + {:ok, lv, _html} = + conn + |> log_in_user(user) + |> live(~p"/users/settings") + + lv + |> element(~s(button[phx-click="settings_tab"][phx-value-tab="api_tokens"])) + |> render_click() + + lv + end + + test "creating a token shows it once, and it works", %{conn: conn} do + user = AccountsFixtures.user_fixture() + lv = open_tab(conn, user) + + lv + |> form("#api-token-form", api_token: %{name: "release script", expires_in_days: "30"}) + |> render_submit() + + token = + lv + |> element("#api-token-value") + |> render() + |> then(&Regex.run(~r/gamend_pat_[\w-]+/, &1)) + |> hd() + + assert {:ok, verified, row} = ApiTokens.verify(token) + assert verified.id == user.id + assert row.name == "release script" + assert has_element?(lv, "#user-api-token-#{row.id}") + + # Dismissed, it is gone from the page for good; the list keeps only a hint. + lv |> element(~s(button[phx-click="api_token_dismiss"])) |> render_click() + refute render(lv) =~ token + assert render(lv) =~ "gamend_pat_" <> row.hint + end + + test "revoking a token stops it", %{conn: conn} do + user = AccountsFixtures.user_fixture() + {:ok, token, row} = ApiTokens.create(user, %{"name" => "old"}) + lv = open_tab(conn, user) + + lv + |> element(~s(button[phx-click="api_token_revoke"][phx-value-id="#{row.id}"])) + |> render_click() + + refute has_element?(lv, "#user-api-token-#{row.id}") + assert ApiTokens.verify(token) == :error + end + + test "lists only the user's own tokens", %{conn: conn} do + user = AccountsFixtures.user_fixture() + {:ok, _, mine} = ApiTokens.create(user, %{"name" => "mine"}) + {:ok, _, theirs} = ApiTokens.create(AccountsFixtures.user_fixture(), %{"name" => "theirs"}) + + lv = open_tab(conn, user) + + assert has_element?(lv, "#user-api-token-#{mine.id}") + refute has_element?(lv, "#user-api-token-#{theirs.id}") + end + + test "a missing name is refused on the form", %{conn: conn} do + user = AccountsFixtures.user_fixture() + lv = open_tab(conn, user) + + html = + lv + |> form("#api-token-form", api_token: %{name: "", expires_in_days: "90"}) + |> render_submit() + + assert html =~ "can't be blank" or html =~ "can't be blank" + assert ApiTokens.count(user.id) == 0 + end +end diff --git a/apps/gamend_web/test/gamend_web/live/user_live/settings_payments_test.exs b/apps/gamend_web/test/gamend_web/live/user_live/settings_payments_test.exs index 7a532f9e9..a27bff387 100644 --- a/apps/gamend_web/test/gamend_web/live/user_live/settings_payments_test.exs +++ b/apps/gamend_web/test/gamend_web/live/user_live/settings_payments_test.exs @@ -11,6 +11,16 @@ defmodule GamendWeb.UserLive.SettingsPaymentsTest do end defmodule StripeAdapter do + # Runs in the LiveView process, so the return URL rides back in the + # redirect target rather than a message to the test. + def create_billing_portal_session("cus_settings_portal", return_url) do + {:ok, + %{ + "id" => "bps_settings", + "url" => "https://billing.stripe.test/p/session?back=" <> URI.encode_www_form(return_url) + }} + end + def cancel_subscription_at_period_end("sub_settings_cancel") do {:ok, %{ @@ -105,6 +115,47 @@ defmodule GamendWeb.UserLive.SettingsPaymentsTest do assert entitlement.metadata["stripe_subscription_cancel_at_period_end"] == true end + test "Manage billing opens the Stripe portal for an account that paid through Stripe", %{ + conn: conn + } do + user = AccountsFixtures.user_fixture() + {_product, provider_product} = create_consumable_provider_product("stripe") + {:ok, purchase} = Payments.create_purchase(user, provider_product) + + {:ok, _purchase} = + Payments.fulfill_purchase(purchase, %{ + "stripe_session" => %{"id" => "cs_portal", "customer" => "cus_settings_portal"} + }) + + assert Payments.stripe_customer_id(user) == "cus_settings_portal" + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/users/settings?tab=payments") + + assert {:error, {:redirect, %{to: "https://billing.stripe.test/p/session?back=" <> back}}} = + view |> element("#open-stripe-portal") |> render_click() + + assert back |> URI.decode_www_form() |> String.ends_with?("/users/settings?tab=payments") + end + + test "no Manage billing button for an account that never paid through Stripe", %{conn: conn} do + user = AccountsFixtures.user_fixture() + {_product, provider_product} = create_consumable_provider_product("apple") + {:ok, purchase} = Payments.create_purchase(user, provider_product) + {:ok, _purchase} = Payments.fulfill_purchase(purchase) + + assert Payments.stripe_customer_id(user) == nil + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/users/settings?tab=payments") + + refute has_element?(view, "#open-stripe-portal") + end + defp create_consumable_provider_product(provider) do sku = "coins_#{System.unique_integer([:positive])}" diff --git a/apps/gamend_web/test/gamend_web/plugs/host_plugs_test.exs b/apps/gamend_web/test/gamend_web/plugs/host_plugs_test.exs new file mode 100644 index 000000000..35cf7d139 --- /dev/null +++ b/apps/gamend_web/test/gamend_web/plugs/host_plugs_test.exs @@ -0,0 +1,60 @@ +defmodule GamendWeb.HostPlugsTest do + # Not async: `:host_plugs` is application env, read by the endpoint on every + # request, and another test's request must not meet this test's plug. + use GamendWeb.ConnCase, async: false + + defmodule GamesHost do + @behaviour Plug + import Plug.Conn + + def init(opts), do: Keyword.put_new(opts, :host, "games.example.test") + + def call(%{host: host} = conn, opts) do + if host == opts[:host] do + conn + |> put_resp_content_type("text/plain") + |> send_resp(200, "game at #{conn.request_path}") + |> halt() + else + conn + end + end + end + + setup do + previous = Application.get_env(:gamend_web, :host_plugs) + Application.put_env(:gamend_web, :host_plugs, [GamesHost]) + + on_exit(fn -> + if previous, + do: Application.put_env(:gamend_web, :host_plugs, previous), + else: Application.delete_env(:gamend_web, :host_plugs) + end) + end + + test "a host plug answers its own host before the trailing-slash redirect and the session", %{ + conn: conn + } do + conn = get(%{conn | host: "games.example.test"}, "/my-game/") + + assert conn.status == 200 + assert conn.resp_body == "game at /my-game/" + assert get_resp_header(conn, "set-cookie") == [] + # Before SecurityHeaders too: the plug decides what its host is framed by. + assert get_resp_header(conn, "x-frame-options") == [] + end + + test "every other host goes through the site as before", %{conn: conn} do + conn = get(conn, "/blog/") + + assert conn.status == 301 + assert get_resp_header(conn, "location") == ["/blog"] + end + + test "a plug given as {module, opts} is initialised with them", %{conn: conn} do + Application.put_env(:gamend_web, :host_plugs, [{GamesHost, host: "other.example.test"}]) + + assert get(%{conn | host: "other.example.test"}, "/x").resp_body == "game at /x" + assert get(%{conn | host: "games.example.test"}, "/blog/").status == 301 + end +end diff --git a/apps/gamend_web/test/gamend_web/plugs/ip_ban_test.exs b/apps/gamend_web/test/gamend_web/plugs/ip_ban_test.exs index d08fe77a3..5b66b93b3 100644 --- a/apps/gamend_web/test/gamend_web/plugs/ip_ban_test.exs +++ b/apps/gamend_web/test/gamend_web/plugs/ip_ban_test.exs @@ -60,4 +60,36 @@ defmodule GamendWeb.Plugs.IpBanTest do :ok = IpBan.apply_remote(:unbanned, "203.0.113.13", nil) refute IpBan.banned?("203.0.113.13") end + + describe "IPv6" do + test "a ban covers the address's /64, and is listed as the /64" do + :ok = IpBan.ban("2001:db8:5:6::1") + + assert IpBan.banned?("2001:db8:5:6:ffff::2") + refute IpBan.banned?("2001:db8:5:7::1") + assert [{"2001:db8:5:6::/64", :infinity}] = IpBan.list_bans() + assert [%{ip: "2001:db8:5:6::/64"}] = IpBans.list_active() + + :ok = IpBan.unban("2001:db8:5:6::abcd") + refute IpBan.banned?("2001:db8:5:6::1") + end + + test "a ban stored per address, as before, still holds and still lifts" do + :ok = IpBan.apply_remote(:banned, "2001:db8:9::1", nil) + assert IpBan.banned?("2001:db8:9::1") + + :ok = IpBan.unban("2001:db8:9::1") + refute IpBan.banned?("2001:db8:9::1") + end + + test "the plug refuses any address in a banned /64" do + :ok = IpBan.ban("2001:db8:5:6::1") + + conn = %{Phoenix.ConnTest.build_conn() | remote_ip: {0x2001, 0xDB8, 5, 6, 7, 7, 7, 7}} + assert IpBan.call(conn, []).halted + + other = %{Phoenix.ConnTest.build_conn() | remote_ip: {0x2001, 0xDB8, 5, 8, 0, 0, 0, 1}} + refute IpBan.call(other, []).halted + end + end end diff --git a/apps/gamend_web/test/gamend_web/user_auth_test.exs b/apps/gamend_web/test/gamend_web/user_auth_test.exs index 8413ac9a0..8a71a4856 100644 --- a/apps/gamend_web/test/gamend_web/user_auth_test.exs +++ b/apps/gamend_web/test/gamend_web/user_auth_test.exs @@ -240,6 +240,39 @@ defmodule GamendWeb.UserAuthTest do end end + describe "session length (auth.session_days)" do + setup do + Gamend.SettingsHelpers.put(:gamend_core, Accounts, :session_days, 2) + on_exit(fn -> Gamend.SettingsHelpers.delete(:gamend_core, Accounts, :session_days) end) + end + + test "the remember-me cookie lasts as long as the session", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + assert %{max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert max_age == 2 * 86_400 + end + + test "a session renews at half its length and is gone after it", %{conn: conn, user: user} do + young = Accounts.generate_user_session_token(user) + offset_user_token(young, -23, :hour) + + kept = conn |> put_session(:user_token, young) |> UserAuth.fetch_current_scope_for_user([]) + assert get_session(kept, :user_token) == young + + old = Accounts.generate_user_session_token(user) + offset_user_token(old, -25, :hour) + + renewed = conn |> put_session(:user_token, old) |> UserAuth.fetch_current_scope_for_user([]) + assert renewed.assigns.current_scope.user_id == user.id + refute get_session(renewed, :user_token) == old + + gone = Accounts.generate_user_session_token(user) + offset_user_token(gone, -3, :day) + refute Accounts.get_user_by_session_token(gone) + end + end + describe "on_mount :mount_current_scope" do setup %{conn: conn} do %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} diff --git a/clients/cpp_template/include/gamend/auth.hpp b/clients/cpp_template/include/gamend/auth.hpp index 9fc45ab7b..f10671d5e 100644 --- a/clients/cpp_template/include/gamend/auth.hpp +++ b/clients/cpp_template/include/gamend/auth.hpp @@ -1,7 +1,7 @@ // Signing in, and staying signed in. // -// Gamend issues a short-lived access token (`expires_in`, 15 minutes) and a -// 30-day refresh token. `Auth` keeps both, refreshes the access token when +// Gamend issues a short-lived access token (`expires_in`, 15 minutes by +// default) and a refresh token (30 days by default). `Auth` keeps both, refreshes the access token when // three quarters of its life have passed, and `Rest` refreshes once more on // a 401. The game keeps the session between runs where its platform keeps // secrets: `on_session_changed` hands it over, `restore` takes it back. diff --git a/clients/cpp_template/include/gamend/session.hpp b/clients/cpp_template/include/gamend/session.hpp index f2e615d6b..00e55277c 100644 --- a/clients/cpp_template/include/gamend/session.hpp +++ b/clients/cpp_template/include/gamend/session.hpp @@ -15,7 +15,7 @@ struct Session { std::string user_id; std::string username; // empty when the sign-in did not say (OAuth) std::string display_name; - /// The access token's lifetime in seconds, as the server said (900). + /// The access token's lifetime in seconds, as the server said (900 by default). std::int64_t expires_in = 0; /// When the access token lapses, in Unix seconds; 0 when unknown. std::int64_t expires_at = 0; diff --git a/config/dev.exs b/config/dev.exs index f08baa7f6..c83712a72 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -171,8 +171,7 @@ config :phoenix_live_view, # Configure Guardian for development config :gamend_web, GamendWeb.Auth.Guardian, issuer: "gamend", - secret_key: "l/tTJZ4KUNjIfiUsNQDQLWOTgFlyiOz8RQ2EgSRa7mopMzPLJuu7/8s5pA7iiSgO", - ttl: {15, :minutes} + secret_key: "l/tTJZ4KUNjIfiUsNQDQLWOTgFlyiOz8RQ2EgSRa7mopMzPLJuu7/8s5pA7iiSgO" # Looser throttling for local iteration. These were previously the *implicit* # dev values — the production numbers only ever applied inside runtime.exs's diff --git a/config/host_config.exs b/config/host_config.exs index 209076105..7ffdb62b2 100644 --- a/config/host_config.exs +++ b/config/host_config.exs @@ -261,10 +261,10 @@ config :gamend_web, GamendWeb.Auth.Guardian, issuer: "gamend", secret_key: "REPLACE_THIS_IN_RUNTIME_CONFIG" -# WebRTC DataChannel support (requires ex_webrtc + ex_sctp deps) -config :gamend_web, :webrtc, - enabled: true, - ice_servers: [%{urls: "stun:stun.l.google.com:19302"}] +# WebRTC DataChannel support (requires ex_webrtc + ex_sctp deps). The ICE +# servers are the GAMEND_WEBRTC_* settings; an `ice_servers:` list here would +# replace them outright. +config :gamend_web, :webrtc, enabled: true # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/config/test.exs b/config/test.exs index d38fce5a1..2e2615c5e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -85,6 +85,10 @@ config :gamend_core, async_inline: true # ticker supervised but idle. Tests drive Gamend.Tournaments.tick/0 directly. config :gamend_core, Gamend.Tournaments.Ticker, enabled: false +# The live retention cycle would sweep outside any sandbox every minute; the +# full sweep's first run is five minutes out, past any test. +config :gamend_core, Gamend.Retention, live_interval_seconds: 0 + # Same for the matchmaking sweep: no sandbox connection, and on SQLite it # collides with the test's open write transaction ("database is locked"). # Tests drive Gamend.Matchmaking.Worker.sweep/0 directly. @@ -113,8 +117,7 @@ config :phoenix_live_view, # Configure Guardian for testing config :gamend_web, GamendWeb.Auth.Guardian, issuer: "gamend", - secret_key: "dJoNJZBOt08JlBREyPV5xvuOdwgHPORxK9WHp/k3Cs+g0R9ctyheJ8/CMeg/AdI1", - ttl: {15, :minutes} + secret_key: "dJoNJZBOt08JlBREyPV5xvuOdwgHPORxK9WHp/k3Cs+g0R9ctyheJ8/CMeg/AdI1" # Disable rate limiting in tests config :gamend_web, GamendWeb.Plugs.RateLimiter, enabled: false diff --git a/mix.lock b/mix.lock index c46f2ba2d..cf89494f1 100644 --- a/mix.lock +++ b/mix.lock @@ -22,7 +22,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.24.1", "26665565f075aaf5f83a76a409ffdef4d2f0a2f3c6840bbf0995993fbea2ad32", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "681ca576c74a94944b962eeb7e0cf19aaea517decafd3213afb403ac8f4cd2e3"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.25.0", "309898d694b17a8ca8cd88d648d3a7fe8479b04ea6831a18d97d56ed3541031b", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7da65c7af38dccf228320db32f93ae49650b0afdd850a09fd2fb191554b3faf5"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, @@ -42,7 +42,7 @@ "ex_turn": {:hex, :ex_turn, "0.2.3", "7d10ce9c784c4a01dfb9adf42d94e52000db014b4557287067cd344b2939e0b6", [:mix], [{:ex_stun, "~> 0.2.1", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "f94874c6d240a162ba610deca0e3a42a6dbc2721c37310ec1ab6063ac1e677c0"}, "ex_webrtc": {:hex, :ex_webrtc, "0.17.0", "53586bed51d165cf96e1c496519fe768e95516618d244f22d4db7f03990cab08", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.18.1", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.16.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.2", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.1", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.3", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.1", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.2", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "a1efcc34e6903d1e9a37ae59312beab3ce258a6c30dccd5973a85ed8b545ee98"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, - "exqlite": {:hex, :exqlite, "0.40.0", "d63cb394a588ecbe73f19c56fbbcf397bf6488b5872fcddc9383fb375cf6131d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f83350f2d29a38be1fd38f39081dd36f41fc51bbacf0f4c2927d01308ae331d0"}, + "exqlite": {:hex, :exqlite, "0.41.0", "f7b6d9730d19efd8a2c9d4624172e82d4f98f2094743429c111972994967b4e2", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a7e9b6bed529ab72aa07ed2a925ac109c27e6877a7a8af252361c396a4192855"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, @@ -51,27 +51,27 @@ "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, - "h2": {:hex, :h2, "0.12.0", "f393539ee2728f8118fb2024b6d5f3e2c45e40ceb31b18b4e9bf5e50d028f80f", [:rebar3], [], "hexpm", "beaafc93c54cdc5d623247334d3970cdf4bc66b6b8b296b74ba1d7c7513c3dfc"}, - "hackney": {:hex, :hackney, "4.7.4", "8fe2ddaa3ca27de99d68e682d72b66d07d2331da680f77c8000580a0122c69e6", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.5", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "d07d7e1358353ab6cc75132f058c155287f3e013d43f709fbb79d79eeab98195"}, + "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, + "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "httpoison": {:hex, :httpoison, "3.0.0", "8566a933bb9175236d1ec335978445b67cd1f5b5d3ead6ca4b80be469d41f5d9", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "9130197b7658901c493d6fcfb842fb9676300fa8a6c8ed058c8889cf1a77f3c2"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lumis": {:hex, :lumis, "0.8.0", "981c61af9f79f30ba2fed48dd5d0b2a1a021e5555e4150442be961978e56513f", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d5b71a5b082f32fc1d007d02f03922db95b9caec335b03a1985edb726c59f63e"}, + "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, - "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, - "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, + "mdex": {:hex, :mdex, "0.14.0", "6bb28b49682465f802e9a129db4319e48a34edf9626e8186c1d5ea29de3f2a81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.9", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.9", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "60e591041b7f5811004cc275e8d3ae1dffb6958159812402427264b444de5080"}, + "mdex_native": {:hex, :mdex_native, "0.2.9", "898cc5514d556c4d46fa43035d6fc925116413bb3821c5a71d4bd42f87a90b85", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "7470e6051b6193c620be02acbc12f1acd758250a60c13695ed36ebda512267ac"}, "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.2.4", "4aa7d91255d0f325d2350a8c655667d38a3995ba073eef4907d467c399f001e3", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "81c12cbe2c8bc5f547c6a8a11a71e2f55995a312c202bbc9b3ddc60c39818b91"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mmdb2_decoder": {:hex, :mmdb2_decoder, "3.0.1", "78e3aedde88035c6873ada5ceaf41b7f15a6259ed034e0eaca72ccfa937798f0", [:mix], [], "hexpm", "316af0f388fac824782d944f54efe78e7c9691bbbdb0afd5cccdd0510adf559d"}, "nebulex": {:hex, :nebulex, "3.0.4", "b55f73e5cd455a57a4dd25df9099806b9bae2384653a76a2fa2fa3184b3abb57", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "446afc6d3f701ba991f1fb0eee36c600f888530e52f30f80b308480ad65faab6"}, @@ -98,7 +98,7 @@ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "pigeon": {:hex, :pigeon, "2.1.0", "993a253a1c9cddefc81bc252943a3eff35a1a8f36322eff0b5087fb16588dbec", [:mix], [{:goth, "~> 1.4.3", [hex: :goth, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:joken, "~> 2.1", [hex: :joken, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "710a7b8c896848a2bddd3e23018e54e12d6b577fa0d233fb638d5fbfa3406c89"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, @@ -107,9 +107,9 @@ "prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, "qex": {:hex, :qex, "0.5.2", "a0c861a2de2380314c23ef592349824ca9016c5845380667ff1d9a22a8796f9b", [:mix], [], "hexpm", "6fb81bf3ae354a9abb471b9561538ea3e8540125d803b00f45cbccff52f00496"}, - "quic": {:hex, :quic, "1.8.2", "c315176d2c4fad0725e52f2a8033b96d01c4fecfc7e6a7333615ff10041142a5", [:rebar3], [], "hexpm", "274d2f41ee9c00d8d6415248df9fb5637381fe69b5387771dc37e91038a65479"}, + "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, - "redix": {:hex, :redix, "1.9.1", "99473ccc538b97eb169beea133e788c236850e84ec3bcc785ef414ffca9fb2e2", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b873953d06f893f88bb92a515ff9f9c0b65662576de27fb810e76cff3609ebab"}, + "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, @@ -132,7 +132,7 @@ "uri_query": {:hex, :uri_query, "0.2.0", "0f5e0f7ea6d9e6a7fb4929a81df9ecd756e3c71bdee5c9bc14e57d90069a82f7", [:mix], [], "hexpm", "e99f50a6af7c6643dff948db152a6a420bfe446aaec7f0924cfcdb710c175e63"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, - "webtransport": {:hex, :webtransport, "0.4.5", "0e387202bbe707389fe81373ef8c56faa9d5aa321bb4800fa4765ee7c1399785", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 1.8.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "bcb512239e48e551d5bd5c667312a9a7de4f29b89d84b1d33c5af44e1f3f730d"}, + "webtransport": {:hex, :webtransport, "0.4.7", "8e0abd5875daab05c7020b8fc0c3b318fe93aa9329302fe5ecda2a0f953cf688", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "9d2fcbfc561a172cf6f2eef00b89fc89774c039ce850414356964c147707f85e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "zarex": {:hex, :zarex, "1.0.6", "f657ed1187e6e90472e24c92b1fd5bf3f846e74bd240bd77276c13f336a8d168", [:mix], [], "hexpm", "b628a9b0bc312f278af2c288078c31fd4757224b82d768e91bcf3bedbe3a50e7"}, diff --git a/modules/plugins/webrtc_lobby_hook/mix.lock b/modules/plugins/webrtc_lobby_hook/mix.lock index 26ccb91bd..b46f88b5c 100644 --- a/modules/plugins/webrtc_lobby_hook/mix.lock +++ b/modules/plugins/webrtc_lobby_hook/mix.lock @@ -3,7 +3,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, diff --git a/priv/docs/10-setup/40-data-schema.md b/priv/docs/10-setup/40-data-schema.md index ffc917081..4bcb4b5b9 100644 --- a/priv/docs/10-setup/40-data-schema.md +++ b/priv/docs/10-setup/40-data-schema.md @@ -48,6 +48,7 @@ These hold everywhere, so the tables below list only what is specific to them: | `lobby_id` | FK lobbies | Current lobby, nullable. **Not** cleared on disconnect | | `party_id` | FK parties | Current party, nullable | | `confirmed_at` | utc_datetime | Email confirmation | +| `deletion_scheduled_at` | utc_datetime | When an account its owner deleted goes, under `GAMEND_AUTH_DELETION_GRACE_DAYS`; null otherwise | | `metadata` | map | Yours | ## lobbies @@ -62,7 +63,7 @@ These hold everywhere, so the tables below list only what is specific to them: | `max_users` | integer | Default 8 | | `is_hidden` | boolean | Excluded from public listings | | `is_locked` | boolean | Blocks joins; server code may pass `bypass_lock` | -| `password_hash` | string | bcrypt; optional join password | +| `password_hash` | string | Argon2id (old bcrypt hashes still verify); optional join password | | `state` | string | Lifecycle word. Core sets `created`; the game owns the vocabulary (enforced in `before_lobby_state_change`) | | `state_changed_at` | utc_datetime | When `state` last changed | | `slowdown` | integer | Chat slow-mode seconds, 0 = off | @@ -310,6 +311,7 @@ ledger is the audit trail; the balance is a cache of it. |---|---|---| | `users_tokens` | Session, magic-link and email-change tokens, pruned on their own expiry | Authentication | | `oauth_sessions` | In-flight OAuth handshakes, pruned daily | Authentication | +| `login_lockouts` | Failed passwords per email address (a SHA-256 of it, never the address) and the lock they set, pruned once both run out | Authentication | | `ip_bans` | Address bans, with optional expiry | — | | `matchmaking_tickets` | Queue state, one per user or party | Matchmaking | | `ready_checks`, `ready_check_participants` | "Everyone must answer" boards on a lobby or party, one row per participant | Matchmaking | diff --git a/priv/docs/20-authentication/10-authentication.md b/priv/docs/20-authentication/10-authentication.md index 9d10b8117..2ef4287e0 100644 --- a/priv/docs/20-authentication/10-authentication.md +++ b/priv/docs/20-authentication/10-authentication.md @@ -26,10 +26,11 @@ magic-link forms; it does not apply to any of the game-client flows. ## JWT token flow (Email / Password / Device) A game client signs a player up with `POST /api/v1/register` (`email`, -`password`, optional `username`). It sends the confirmation email as the -browser form does and answers `201` with the same tokens as login; a taken -email or username is `409`, and an email that cannot be sent is `503` with no -account kept. +`password`, optional `username`). It answers `201` with the same tokens as +login without waiting on the mail server: the confirmation email is queued, +as for the browser form, and sent and retried in the background. A taken +email or username is `409`, and a plugin that refuses the sign-up is +`403 registration_refused`. Deleting an account (`DELETE /api/v1/me`) sends `current_password` when the account has one. @@ -53,12 +54,40 @@ account has one. ◄── { access_token, refresh_token } ◄─ New access token ``` -Access tokens are short-lived (15 min). Refresh tokens last 30 days. Both are signed JWTs, but each authenticated request still loads the user from the database. So a token stops working once the account is deactivated or its tokens are revoked (logout, password or email change). +Access tokens are short-lived: 15 minutes by default, set with `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES`. Refresh tokens last 30 days by default, set with `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS`. Every login and refresh answers `expires_in`, the access token's lifetime in seconds; schedule the refresh from it rather than from a fixed interval. A changed setting applies to tokens issued after it. Both are signed JWTs, but each authenticated request still loads the user from the database. So a token stops working once the account is deactivated or its tokens are revoked (logout, password or email change). -Refresh returns a new access token and sends back the same refresh token; it does not issue a new one. Log in again before the refresh token's 30 days run out. +Refresh returns a new access token and sends back the same refresh token; it does not issue a new one. Log in again before the refresh token runs out. Token responses wrap their fields in a `data` object (`{"data": {"access_token": "..."}}`); the diagrams leave that wrapper out. +## Browser sessions and emailed links + +The website signs in with a session cookie rather than JWTs. The windows are settings on the `auth` group: + +| Setting | Default | What it bounds | +|---|---|---| +| `GAMEND_AUTH_SESSION_DAYS` | `14` | A browser session and its remember-me cookie. An active session is renewed once it is half this old, so only an idle one runs out. | +| `GAMEND_AUTH_MAGIC_LINK_MINUTES` | `15` | An emailed login link. Capped at 60: whoever can read the email can sign in while it lives. | +| `GAMEND_AUTH_CONFIRM_EMAIL_DAYS` | `7` | The link that confirms a new account's email. | +| `GAMEND_AUTH_CHANGE_EMAIL_DAYS` | `7` | The link that confirms a changed email address. | +| `GAMEND_AUTH_SUDO_MODE_MINUTES` | `10` | How recently a user must have signed in to open the settings that change their password or email. Submitting the form is allowed ten minutes more. | + +## Failed password lockout + +The per-IP auth rate limit caps how fast one machine can guess, not guesses spread across many machines at one account. So failed passwords are also counted per email address: `GAMEND_AUTH_LOCKOUT_ATTEMPTS` failures (default `10`, `0` turns it off) within `GAMEND_AUTH_LOCKOUT_WINDOW_MINUTES` (default `15`) lock password sign-in for that address for `GAMEND_AUTH_LOCKOUT_MINUTES` (default `15`). The count lives in the database, so it holds across instances, and a correct password clears it. + +While locked, the password is not checked at all: `POST /api/v1/login` answers `429 account_locked` with a `Retry-After` header, and the browser form says to try again later. An address with no account counts and locks exactly like one with an account, so the lock never reveals which addresses are registered. Only password sign-in is locked. An emailed login link and provider sign-ins still work, so someone failing at a player's password cannot shut the player out. An admin can lift a lock from the user's page in **Admin → Users**. + +## Deleting an account + +`DELETE /api/v1/me` and **Delete account** on the settings page delete the account at once by default. With `GAMEND_AUTH_DELETION_GRACE_DAYS` set, they schedule it that many days out instead, and sign the account out everywhere (sessions, access, refresh and personal API tokens). Until the date: + +- **API sign-ins are refused** with `403 deletion_scheduled`: password, device and provider logins alike. A game client may sign in on its own, from a stored device id or a silent provider login, and that must not undo a deletion the player asked for. +- **Signing in on the website keeps the account**, with a message saying so. A person is at the keyboard there. +- **An admin can keep it** from the user's page in **Admin → Users**, where a scheduled account is marked **Deleting**. + +On the date, the retention sweep deletes the account with `Gamend.Accounts.delete_user/1`, so every cleanup and the `after_user_deleted` hook run as for an immediate deletion. Admin deletions and the retention sweeps of inactive accounts never wait. + ## OAuth: browser redirect (polling) For game clients that can't handle OAuth natively. The client opens a browser, then polls for the result. @@ -189,6 +218,14 @@ then sends a Turnstile token as `captcha_token` (from a web export or a webview), and is answered `403 captcha_required` / `captcha_invalid` without one. A client that cannot render the widget can then no longer register. +For a public server that takes email sign-ups from game clients, turn it on +if your clients can show the widget. The per-IP limit is 10 sign-ups a +minute, and IPv6 clients count per /64 network, but a botnet brings its own +addresses. Every sign-up it makes queues an email to an address it chose, +and bounces from made-up addresses cost your domain its reputation. Clients +that cannot show the widget can sign players in with device login, which +sends no email, and add an email later. + ### Setup Create a widget at diff --git a/priv/docs/20-authentication/85-api-tokens.md b/priv/docs/20-authentication/85-api-tokens.md new file mode 100644 index 000000000..9aee7ef49 --- /dev/null +++ b/priv/docs/20-authentication/85-api-tokens.md @@ -0,0 +1,57 @@ +--- +icon: hero-key +--- + +# Personal API tokens + +An access token from `POST /api/v1/login` lasts fifteen minutes and needs a +password. That suits a game client, not a script that runs unattended, and an +account made with a social login has no password at all. A **personal API +token** is made once on the settings page and lasts until it expires or is +revoked. + +## Make one + +Settings → **API tokens**: give it a name and a lifetime (30, 90 or 365 days, +or never), then **Create token**. With `GAMEND_AUTH_API_TOKEN_MAX_DAYS` set, +the lifetimes stop at that many days and "never" is gone; the cap also ends +tokens made before it, that many days after their creation. The token is shown once: + +```text +gamend_pat_Qm9x… +``` + +Copy it into the secret store of whatever will use it. Gamend keeps only a +SHA-256 of it, so it cannot be shown again; the list shows its first +characters so you can tell which secret holds which token. + +## Use it + +Anywhere an access token works, as a Bearer token: + +```sh +curl https://your-server/api/v1/me \ + -H "authorization: Bearer gamend_pat_Qm9x…" +``` + +It acts as you, on every API route an access token reaches. It does not work +on the realtime socket, and it cannot create another token: tokens are made on +the settings page only, so a leaked one cannot mint its replacements. + +## When it stops working + +- It reaches its expiry, or you press **Revoke**. The next request is a 401 + with `"error": "invalid_token"`. +- You change your password or email, or sign out everywhere. Each of those + revokes every token made before it, for the same reason it ends every + session: if someone else had your account, their token should not outlive + the reset that locks them out. +- The account is deactivated. + +Tokens that can never work again are deleted by retention +(`dead_api_tokens`). + +## Limits + +`GAMEND_LIMITS_MAX_API_TOKENS_PER_USER` (default 10) caps how many one account +may hold. Revoke one to make room. diff --git a/priv/docs/30-clients/20-js-sdk.md b/priv/docs/30-clients/20-js-sdk.md index 5e0d9e2f1..c915353b2 100644 --- a/priv/docs/30-clients/20-js-sdk.md +++ b/priv/docs/30-clients/20-js-sdk.md @@ -46,8 +46,10 @@ const { access_token, refresh_token, user_id } = (await authApi.login({ apiClient.defaultHeaders = { Authorization: `Bearer ${access_token}` }; ``` -Access tokens last 15 minutes, refresh tokens 30 days. Refresh before the -access token expires, or retry once on a `401`: +Access tokens last 15 minutes and refresh tokens 30 days, unless the server +sets otherwise; `expires_in` on every login and refresh gives the access +token's lifetime in seconds. Refresh before the access token expires, or retry +once on a `401`: ```javascript const refreshed = (await authApi.refreshToken({ diff --git a/priv/docs/30-clients/25-cpp-sdk.md b/priv/docs/30-clients/25-cpp-sdk.md index e6fcef942..6e3806c8e 100644 --- a/priv/docs/30-clients/25-cpp-sdk.md +++ b/priv/docs/30-clients/25-cpp-sdk.md @@ -99,8 +99,9 @@ sent: the callback gets an error naming it. ## Sessions -The access token lasts 15 minutes and the refresh token 30 days. The SDK -refreshes the access token before it lapses, and once more if a call answers +The access token lasts 15 minutes and the refresh token 30 days, unless the +server sets otherwise. The SDK reads the access token's lifetime from +`expires_in` and refreshes it before it lapses, and once more if a call answers `401`. To stay signed in across runs, keep what `on_session_changed` hands you and give it back to `restore`: diff --git a/priv/docs/30-clients/50-webrtc.md b/priv/docs/30-clients/50-webrtc.md index 92cb9f5a6..303d40308 100644 --- a/priv/docs/30-clients/50-webrtc.md +++ b/priv/docs/30-clients/50-webrtc.md @@ -38,13 +38,12 @@ Client Server (ex_webrtc) | `"events"` | Yes | Yes | Game events, hook RPC | | `"state"` | No | No | High-frequency state (positions) | -Configure ICE in the host config (`enabled: false` rejects offers at runtime): +The server peer's ICE servers are settings: `GAMEND_WEBRTC_STUN_URLS` (default Google's public STUN server) and, for a server behind NAT or on a network that filters UDP, a TURN relay with `GAMEND_WEBRTC_TURN_URLS`, `GAMEND_WEBRTC_TURN_USERNAME` and `GAMEND_WEBRTC_TURN_CREDENTIAL`. The URL settings take comma-separated lists. These are the server's own servers; a client passes its own to its peer connection. `enabled: false` in the host config rejects offers at runtime, and an `ice_servers:` list there replaces the settings outright: ```text config :gamend_web, :webrtc, enabled: true, ice_servers: [%{urls: "stun:stun.l.google.com:19302"}] - # add a TURN entry for restrictive NATs ``` ### UserChannel signaling events diff --git a/priv/docs/40-gameplay/02-lobbies.md b/priv/docs/40-gameplay/02-lobbies.md index 57bb97564..12b4a2bc4 100644 --- a/priv/docs/40-gameplay/02-lobbies.md +++ b/priv/docs/40-gameplay/02-lobbies.md @@ -14,7 +14,7 @@ POST /lobbies creates a lobby with the caller as host and first member; POST /lo |---|---| | `is_hidden` | Out of GET /lobbies and the lobby-list feed. Members and the pinned WebRTC host still see it; to everyone else GET /lobbies/:id answers 404, not 403 - a 403 would confirm the lobby exists. Joining one by id is refused too (403 `cannot_join`): a hidden lobby is invite-only, and server-side code may pass `bypass_hidden` to seat a player in it. | | `is_locked` | Nobody can join, and the lobby cannot be spectated. Server-side code may pass `bypass_lock`; no player-facing surface does. | -| `password` | Stored as a bcrypt hash; join must carry the password. Quick join never considers passworded lobbies. | +| `password` | Stored as an Argon2id hash, like account passwords (older bcrypt hashes still verify); join must carry the password. Quick join never considers passworded lobbies. | | `max_users` | Seat cap (default 8), enforced under the lobby's advisory lock. Shrinking it below the current member count is refused with `too_small`. | POST /lobbies/quick_join finds a room instead of asking the player to pick one: it tries the oldest visible, unlocked, passwordless candidates whose max_users and metadata match the request, skips any that are full or that the game's before_lobby_join hook rejects, and creates a fresh lobby with the caller as host when none will take them. diff --git a/priv/docs/40-gameplay/20-tournaments.md b/priv/docs/40-gameplay/20-tournaments.md index 9acc09b93..6da624e2b 100644 --- a/priv/docs/40-gameplay/20-tournaments.md +++ b/priv/docs/40-gameplay/20-tournaments.md @@ -20,6 +20,8 @@ Single-elimination bracket tournaments: players register during a registration w and config — "current occurrence per slug" works like leaderboard seasons. ``` +Transitions, draws, match deadlines and recurrence run on a tick every `GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS` (default `30`), so a round can start or time out up to that late. Lower it for short rounds. + ## Entries A bracket side is an entry, and an entry is a leader (one user). For team tournaments (team_size 2+, advisory) the leader registers and team composition is game policy, enforced in hooks if you care. The server tracks who leads, not who shows up. Entry states: registered → active (after the draw) → eliminated or winner. diff --git a/priv/docs/40-gameplay/30-matchmaking.md b/priv/docs/40-gameplay/30-matchmaking.md index 60c555b55..ce7127470 100644 --- a/priv/docs/40-gameplay/30-matchmaking.md +++ b/priv/docs/40-gameplay/30-matchmaking.md @@ -25,6 +25,8 @@ POST /matchmaking/tickets ──► ticket (queued) "match_found" pushed on each player's user channel ``` +A ticket may set its own min_players and max_players; one that leaves them out gets the server's, GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS (2) and GAMEND_LIMITS_MATCHMAKING_DEFAULT_MAX_PLAYERS (5). + Parameters match exactly: a ticket queued with map=dust2 never joins one with map=inferno. Skill bands, regions or modes are therefore encoded by the game client (or a server hook) as parameter values, and each distinct combination forms its own queue. Blacklists are applied while the group is being formed, not after: two players who have blocked each other are never put in the same match, and each is matched with someone else instead. A player blocked with everyone ahead of them in the queue is skipped over rather than allowed to stall the players behind them. See the Friends & Blacklist guide. diff --git a/priv/docs/45-storage/20-object-storage.md b/priv/docs/45-storage/20-object-storage.md index 017178bba..44f8cedba 100644 --- a/priv/docs/45-storage/20-object-storage.md +++ b/priv/docs/45-storage/20-object-storage.md @@ -53,7 +53,7 @@ Two limits bound the whole surface: `GAMEND_LIMITS_MAX_UPLOAD_BYTES` (5 MiB per ## Serving -On the local backend, objects are served by `GET /storage/*key` from the app itself; on S3 the object URL points at the bucket (or at `GAMEND_STORAGE_PUBLIC_URL` when set) and that route is unused. Cache policy is keyed by prefix: `avatars/` and `icons/` are immutable for a year, because every change mints a new key and makes their URL content-unique. Everything else revalidates via ETag. The serve route labels only real image bytes as images; any other stored type comes back as an opaque download, never rendered from the app's origin. +On the local backend, objects are served by `GET /storage/*key` from the app itself. On S3 with `GAMEND_STORAGE_PUBLIC_URL` set, the object URL points there. On S3 without it the bucket is private: the stored URL is `/storage/`, which redirects to a link signed for `GAMEND_STORAGE_SIGNED_URL_SECONDS` (default `3600`), so a URL saved as a player's avatar keeps working after any one signed link expires. Upload tickets last `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default `600`) on both backends, and `expires_in` on the ticket says so. Cache policy is keyed by prefix: `avatars/` and `icons/` are immutable for a year, because every change mints a new key and makes their URL content-unique. Everything else revalidates via ETag. The serve route labels only real image bytes as images; any other stored type comes back as an opaque download, never rendered from the app's origin. ## Server scripting @@ -61,7 +61,8 @@ On the local backend, objects are served by `GET /storage/*key` from the app its key = Gamend.Storage.build_key("avatars", user.id, "me.png") {:ok, ticket} = Gamend.Storage.presigned_upload(key, content_type: "image/png") -Gamend.Storage.url(key) # public or signed, backend-dependent +Gamend.Storage.url(key) # stable URL, safe to store +Gamend.Storage.url(key, signed: true) # short-lived signed link (private S3); never store it {:ok, data} = Gamend.Storage.get(key) {:ok, %{size: _, content_type: _}} = Gamend.Storage.stat(key) :ok = Gamend.Storage.delete(key) diff --git a/priv/docs/45-storage/30-data-retention.md b/priv/docs/45-storage/30-data-retention.md index dbfa4c7ba..d93e09c61 100644 --- a/priv/docs/45-storage/30-data-retention.md +++ b/priv/docs/45-storage/30-data-retention.md @@ -4,7 +4,7 @@ icon: hero-clock # Data Retention -A supervised sweeper (`Gamend.Retention`) prunes unbounded tables on a schedule: a first pass five minutes after boot, then every six hours. Each window is one setting, a `GAMEND_RETENTION_*` env var, in days unless the name says otherwise, and `0` means keep forever. Deletes run in batches of 500, are idempotent so several instances sweeping at once is harmless, are failure-isolated per class, and each class emits `[:gamend, :retention, :pruned]` telemetry with its count. +A supervised sweeper (`Gamend.Retention`) prunes unbounded tables on a schedule: a first pass five minutes after boot, then every `GAMEND_RETENTION_INTERVAL_HOURS` (default `6`). The classes that free live game state (offline lobby and party seats, abandoned parties and lobbies) also run on their own short cycle, every `GAMEND_RETENTION_LIVE_INTERVAL_SECONDS` (default `60`), so their minute-long windows hold to within a minute. Each window is one setting, a `GAMEND_RETENTION_*` env var, in days unless the name says otherwise, and `0` means keep forever. Deletes run in batches of `GAMEND_RETENTION_BATCH_SIZE` (default `500`), are idempotent so several instances sweeping at once is harmless, are failure-isolated per class, and each class emits `[:gamend, :retention, :pruned]` telemetry with its count. ## Pruning windows @@ -25,7 +25,7 @@ A supervised sweeper (`Gamend.Retention`) prunes unbounded tables on a schedule: | `GAMEND_RETENTION_UNCONFIRMED_USERS_DAYS` | `30` | Accounts whose only identity is an email never confirmed, inactive for N days. One with a provider login is kept. Find them on the admin Users page with the "Unverified email" filter. | | `GAMEND_RETENTION_INACTIVE_USERS_DAYS` | `0` | Accounts with a real identity, after N days of inactivity — see the warning flow below. | -Client log *sessions* are pruned on the client-logs module's own settings (`retention_days` 14, `retention_flagged_days` 90, keyed off `last_seen_at`). That prunes the searchable index over sessions, not the log lines, which live in the host's log store on its own retention. And some cleanups have no variable at all: expired IP bans, OAuth sessions older than a day, user tokens past their context's validity, and stored avatars whose owner no longer exists are always removed. The full variable list, with types and defaults, is in [Settings](/docs/settings). +Client log *sessions* are pruned on the client-logs module's own settings (`retention_days` 14, `retention_flagged_days` 90, keyed off `last_seen_at`). That prunes the searchable index over sessions, not the log lines, which live in the host's log store on its own retention. And some cleanups have no variable of their own: expired IP bans, OAuth sessions older than a day, user tokens past their context's validity, login lockouts whose window and lock have run out, accounts whose owner deleted them once `GAMEND_AUTH_DELETION_GRACE_DAYS` has passed, and stored avatars whose owner no longer exists are always removed. The full variable list, with types and defaults, is in [Settings](/docs/settings). ## Abandoned lobbies and parties diff --git a/priv/docs/60-operations/35-security.md b/priv/docs/60-operations/35-security.md index c920326c3..b737f9812 100644 --- a/priv/docs/60-operations/35-security.md +++ b/priv/docs/60-operations/35-security.md @@ -4,7 +4,7 @@ icon: hero-shield-check # Security & Rate Limiting -Requests pass through a fixed chain in the endpoint: security headers are set and static files are served first, then the real client IP is extracted from proxy headers, banned IPs are rejected, CORS is applied, and the request is rate-limited, all before the router runs. This guide covers each layer, what it protects against, and what to configure for production. +Requests pass through a fixed chain in the endpoint: security headers are set and static files are served first, then the real client IP is extracted from proxy headers, banned IPs are rejected, CORS is applied, and the request is rate-limited, all before the request body is parsed and before the router runs. A request over its limit is refused without its body being read. This guide covers each layer, what it protects against, and what to configure for production. ## The real client IP @@ -14,7 +14,7 @@ The headers are only parsed when the connecting peer *is* a trusted proxy (loopb ## IP bans -An IP ban is checked in ETS on every request that reaches it: a banned address gets a bare `403` before any routing happens. Static files are served earlier in the chain, so a ban does not cover them. Bans are persisted so they survive restarts, and broadcast over PubSub so every instance in a cluster applies them within moments of the ban being placed. +An IP ban is checked in ETS on every request that reaches it: a banned address gets a bare `403` before any routing happens. An IPv6 address is banned by its /64 network, since one subscriber is usually given a whole /64 and can move around inside it; `ban("2001:db8::1")` is listed as `2001:db8::/64`. An IPv4 address is banned as itself. Static files are served earlier in the chain, so a ban does not cover them. Bans are persisted so they survive restarts, and broadcast over PubSub so every instance in a cluster applies them within moments of the ban being placed. Ban and unban from [/admin/rate_limiting](/admin/rate_limiting) (permanent or with a TTL), or from server code: @@ -26,7 +26,7 @@ GamendWeb.Plugs.IpBan.unban("1.2.3.4") ## Rate limiting -HTTP requests are throttled per client IP, realtime messages per user, using Hammer counters. Each surface has its own bucket so one cannot starve another: +HTTP requests are throttled per client IP (an IPv6 client per /64, for the same reason as bans), realtime messages per user, using Hammer counters. Each surface has its own bucket so one cannot starve another: | Bucket | Scope | Default | Variables | |---|---|---|---| @@ -36,6 +36,8 @@ HTTP requests are throttled per client IP, realtime messages per user, using Ham | WebSocket channel messages | per user | 60 / 10s | `GAMEND_RATELIMIT_WS_LIMIT`, `GAMEND_RATELIMIT_WS_WINDOW_MS` | | WebRTC DataChannel messages | per user | 300 / 10s | `GAMEND_RATELIMIT_DC_LIMIT`, `GAMEND_RATELIMIT_DC_WINDOW_MS` | | ICE candidates | per user | 150 / 30s | `GAMEND_RATELIMIT_ICE_LIMIT`, `GAMEND_RATELIMIT_ICE_WINDOW_MS` | +| Peer-to-peer signaling messages | per user | 300 / 10s | `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT`, `GAMEND_RATELIMIT_SIGNALING_WS_WINDOW_MS` | +| Peer-to-peer ICE candidates | per user | 150 / 30s | `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT`, `GAMEND_RATELIMIT_SIGNALING_ICE_WINDOW_MS` | `GAMEND_RATELIMIT_ENABLED` is the master switch. An HTTP client over the limit gets `429 Too Many Requests` with a `Retry-After` header; a WebSocket client over its budget has the channel closed with a `rate_limited` error, and a flooding WebRTC peer is disconnected. Separate daily quotas cap chat messages and chat reports per user (`GAMEND_LIMITS_MAX_CHAT_MESSAGES_PER_DAY`, `GAMEND_LIMITS_MAX_CHAT_REPORTS_PER_USER_PER_DAY`). diff --git a/priv/docs/60-operations/40-settings.md b/priv/docs/60-operations/40-settings.md index 59bc7b637..11839ea87 100644 --- a/priv/docs/60-operations/40-settings.md +++ b/priv/docs/60-operations/40-settings.md @@ -7,7 +7,7 @@ generated: by `mix gamend.settings.guide` - do not edit by hand; edit the # Settings Every setting the server has, with the environment variable that sets it. -261 settings across 23 groups. +309 settings across 28 groups. A setting is declared in the module that owns it, so this page and `.env.example` are generated from the same source the server reads. The @@ -45,14 +45,38 @@ Live values, and where each one came from, are on the | Variable | Type | Default | Notes | |---|---|---|---| +| `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES` | integer | `15` | Lifetime of API access tokens, in minutes. Login and refresh answer it as expires_in. Applies to tokens issued after the change. | | `GAMEND_AUTH_ANONYMOUS_CAN_UPLOAD_AVATAR` | boolean | `false` | Allow device-only accounts to upload an avatar. Off by default: an anonymous account costs one request to create, so this is the cheapest way for a bot to burn object storage. | +| `GAMEND_AUTH_API_TOKEN_MAX_DAYS` | integer | `0` | Longest lifetime a personal API token may have, in days. Applies to existing tokens too, counted from creation. 0 allows tokens that never expire. | | `GAMEND_AUTH_ARGON2_MEMORY_LOG2` | integer | `14` | Argon2id memory per hash, as a power of two in KiB — 14 is 16 MiB. Peak use is this times the vCPU count, not times the request rate, because the BEAM runs at most one hash per dirty CPU scheduler. Below 12 (4 MiB) it stops being meaningfully memory-hard. | | `GAMEND_AUTH_ARGON2_TIME_COST` | integer | `3` | Argon2id passes over memory. Raise to compensate when lowering memory. | +| `GAMEND_AUTH_CHANGE_EMAIL_DAYS` | integer | `7` | How long the link confirming a new email address stays valid, in days. | +| `GAMEND_AUTH_CONFIRM_EMAIL_DAYS` | integer | `7` | How long an email confirmation link stays valid, in days. | +| `GAMEND_AUTH_DELETION_GRACE_DAYS` | integer | `0` | Days between a player deleting their own account and it being deleted. Signing in on the website within that time keeps the account. 0 deletes at once. | | `GAMEND_AUTH_DEVICE_AUTH_ENABLED` | boolean | `true` | Allow POST /api/v1/login/device. When on, any unknown device_id creates an anonymous account. | | `GAMEND_AUTH_GUARDIAN_SECRET_KEY` | string | - | JWT signing key. Defaults to secret_key_base when unset. Secret - never log or commit it. | +| `GAMEND_AUTH_LOCKOUT_ATTEMPTS` | integer | `10` | Failed passwords for one email address that lock its password sign-in. Counted per address across every IP. 0 disables the lockout. | +| `GAMEND_AUTH_LOCKOUT_MINUTES` | integer | `15` | How long a lock lasts. Emailed login links and provider sign-in still work meanwhile, so the owner is never shut out. | +| `GAMEND_AUTH_LOCKOUT_WINDOW_MINUTES` | integer | `15` | The failures must fall within this many minutes to lock. | +| `GAMEND_AUTH_MAGIC_LINK_MINUTES` | integer | `15` | How long an emailed login link stays valid, in minutes. Capped at 60: anyone who can read the email can sign in while the link lives. | | `GAMEND_AUTH_MIN_PASSWORD_LENGTH` | integer | `8` | Minimum password length enforced at registration and change. | +| `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS` | integer | `30` | Lifetime of API refresh tokens, in days. A refresh keeps its token, so this is how long a client stays signed in without logging in again. | | `GAMEND_AUTH_REQUIRE_ACTIVATION` | boolean | `false` | New accounts cannot log in until an admin activates them (beta mode). | | `GAMEND_AUTH_SECRET_KEY_BASE` | string | - | Signs and encrypts cookies, tokens and LiveView sessions. **Required in production.** Secret - never log or commit it. | +| `GAMEND_AUTH_SESSION_DAYS` | integer | `14` | Lifetime of a browser session and its remember-me cookie, in days. An active session is renewed once it is half this old. | +| `GAMEND_AUTH_SUDO_MODE_MINUTES` | integer | `10` | How recently a user must have signed in to open the settings that change their password or email. Submitting the form is allowed 10 minutes more. | + + +## Background jobs + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `GAMEND_JOBS_PRUNE_AFTER_DAYS` | integer | `7` | Days finished, cancelled and discarded jobs are kept before they are deleted. | +| `GAMEND_JOBS_QUEUE_DEFAULT` | integer | `10` | Per-node concurrent jobs on the default queue. | +| `GAMEND_JOBS_QUEUE_HOOKS` | integer | `20` | Per-node concurrent jobs on the hooks queue: enqueued and scheduled plugin hooks. | +| `GAMEND_JOBS_QUEUE_MAILERS` | integer | `5` | Per-node concurrent email sends. | +| `GAMEND_JOBS_QUEUE_STORAGE` | integer | `5` | Per-node concurrent storage jobs, such as avatar mirroring. | +| `GAMEND_JOBS_QUEUE_WEBHOOKS` | integer | `10` | Per-node concurrent outgoing webhook deliveries. | ## Cache @@ -61,9 +85,12 @@ Live values, and where each one came from, are on the |---|---|---|---| | `GAMEND_CACHE_ENABLED` | boolean | `true` | Set false to bypass caching entirely. | | `GAMEND_CACHE_L2` | atom | `:partitioned` | redis or partitioned. Only used when mode is multi; partitioned needs clustering. | +| `GAMEND_CACHE_MAX_ENTRIES` | integer | `1000000` | Most entries each node's local cache holds. | +| `GAMEND_CACHE_MAX_MEMORY_MB` | integer | `500` | Most memory each node's local cache may use, in MB. Lower it on a small machine: the default alone is most of a 512 MB instance. | | `GAMEND_CACHE_MODE` | atom | `:single` | single (L1 local only) or multi (L1 + a shared L2). | | `GAMEND_CACHE_REDIS_POOL_SIZE` | integer | `10` | | | `GAMEND_CACHE_REDIS_URL` | string | - | Redis URL for the shared L2. **Required in production when `GAMEND_CACHE_MODE` is `multi` and `GAMEND_CACHE_L2` is `redis`.** | +| `GAMEND_CACHE_TTL_MS` | integer | `60000` | How long a cached entity (user, lobby, party, group, KV entry...) is kept, in ms. On a cluster it bounds how stale a node can be when an invalidation is missed. | ## Captcha @@ -135,6 +162,7 @@ Live values, and where each one came from, are on the | Variable | Type | Default | Notes | |---|---|---|---| +| `GAMEND_MAIL_SEND_TIMEOUT_MS` | integer | `30000` | Longest one email send may take before it is abandoned. gen_smtp itself waits up to 20 minutes for each reply from the relay. | | `GAMEND_MAIL_SMTP_FROM_EMAIL` | string | - | | | `GAMEND_MAIL_SMTP_FROM_NAME` | string | `"Gamend"` | | | `GAMEND_MAIL_SMTP_PASSWORD` | string | - | SMTP password, or the provider's API key. Warns if unset once `GAMEND_MAIL_SMTP_RELAY` or `GAMEND_MAIL_SMTP_USERNAME` is set. Secret - never log or commit it. | @@ -146,6 +174,15 @@ Live values, and where each one came from, are on the | `GAMEND_MAIL_SMTP_USERNAME` | string | - | Warns if unset once `GAMEND_MAIL_SMTP_PASSWORD` or `GAMEND_MAIL_SMTP_RELAY` is set. | +## Hooks + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `GAMEND_HOOKS_CALL_TIMEOUT_IN_TRANSACTION_MS` | integer | `5000` | The same, for a hook called inside a database transaction: on SQLite that transaction holds the only write connection while the hook runs. | +| `GAMEND_HOOKS_CALL_TIMEOUT_MS` | integer | `60000` | How long a plugin hook or RPC may run before it is killed, in ms. The caller's request waits that long. | +| `GAMEND_HOOKS_SLOW_THRESHOLD_MS` | integer | `200` | Log a hook call as slow when it takes longer than this, in ms. | + + ## IndexNow | Variable | Type | Default | Notes | @@ -160,10 +197,13 @@ Live values, and where each one came from, are on the | Variable | Type | Default | Notes | |---|---|---|---| +| `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MAX_PLAYERS` | integer | `5` | Largest match a ticket forms when it does not say. At most max_matchmaking_players. | +| `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS` | integer | `2` | Smallest match a ticket forms when it does not say. | | `GAMEND_LIMITS_MATCHMAKING_OFFLINE_GRACE_MS` | integer | `300000` | Grace before an offline player's ticket is pruned; long enough that a brief disconnect keeps its queue position. | | `GAMEND_LIMITS_MATCHMAKING_TICK_MS` | integer | `3000` | Sweep interval of the matchmaking worker. | | `GAMEND_LIMITS_MATCHMAKING_TIMEOUT_MS` | integer | `30000` | How long the oldest ticket waits before a below-max group still forms. | | `GAMEND_LIMITS_MAX_ACTIVE_QUESTS_PER_USER` | integer | `200` | Progress rows a user may hold in the current periods; excess events are ignored. | +| `GAMEND_LIMITS_MAX_API_TOKENS_PER_USER` | integer | `10` | Personal API tokens one user may hold, revoked ones not counted. | | `GAMEND_LIMITS_MAX_CHAT_CONTENT` | integer | `4096` | | | `GAMEND_LIMITS_MAX_CHAT_FILTER_WORDS` | integer | `10000` | Blocklist size cap. Sized to hold the bundled word lists for several languages. | | `GAMEND_LIMITS_MAX_CHAT_FILTER_WORD_LEN` | integer | `64` | | @@ -303,12 +343,21 @@ Live values, and where each one came from, are on the | `GAMEND_PAYMENTS_STEAM_MICROTXN_BASE_URL` | string | - | | | `GAMEND_PAYMENTS_STEAM_WEB_API_KEY` | string | - | Falls back to the OAuth Steam key when unset. Secret - never log or commit it. | | `GAMEND_PAYMENTS_STRIPE_API_VERSION` | string | `"2022-11-15"` | | +| `GAMEND_PAYMENTS_STRIPE_MANAGED_PAYMENTS` | boolean | `false` | Sell through Stripe Managed Payments (Stripe is merchant of record: it charges and remits the buyer's VAT). Accept the terms and set a tax code on every product in the Stripe Dashboard first. | | `GAMEND_PAYMENTS_STRIPE_PRODUCTION_SECRET_KEY` | string | - | sk_live_... key, used when environment is production. Warns if unset when `GAMEND_PAYMENTS_ENVIRONMENT` is `production`. Secret - never log or commit it. | | `GAMEND_PAYMENTS_STRIPE_PRODUCTION_WEBHOOK_SECRET` | string | - | Secret - never log or commit it. | | `GAMEND_PAYMENTS_STRIPE_SANDBOX_SECRET_KEY` | string | - | sk_test_... key, used when environment is sandbox. Secret - never log or commit it. | | `GAMEND_PAYMENTS_STRIPE_SANDBOX_WEBHOOK_SECRET` | string | - | Secret - never log or commit it. | +## Presence + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `GAMEND_PRESENCE_INTERVAL_MS` | integer | `120000` | How often users still marked online after a crash are looked for, in ms. | +| `GAMEND_PRESENCE_STALE_THRESHOLD_S` | integer | `300` | Mark a user offline once their last_seen_at is this many seconds old. Connected sockets refresh it at three fifths of this, at most every 3 minutes. | + + ## Public features | Variable | Type | Default | Notes | @@ -360,6 +409,10 @@ Live values, and where each one came from, are on the | `GAMEND_RATELIMIT_ICE_LIMIT` | integer | `150` | Max ICE candidate messages per window, per user. | | `GAMEND_RATELIMIT_ICE_WINDOW_MS` | integer | `30000` | ICE candidate window, in milliseconds. | | `GAMEND_RATELIMIT_REDIS_URL` | string | - | Redis URL for shared counters. **Required in production when `GAMEND_RATELIMIT_BACKEND` is `redis`.** | +| `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT` | integer | `150` | Max ICE candidates relayed over the signaling channel per window, per user. | +| `GAMEND_RATELIMIT_SIGNALING_ICE_WINDOW_MS` | integer | `30000` | Signaling ICE window, in milliseconds. | +| `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT` | integer | `300` | Max signaling channel messages per window, per user. | +| `GAMEND_RATELIMIT_SIGNALING_WS_WINDOW_MS` | integer | `10000` | Signaling channel window, in milliseconds. | | `GAMEND_RATELIMIT_WS_LIMIT` | integer | `60` | Max WebSocket channel messages per window, per user. | | `GAMEND_RATELIMIT_WS_WINDOW_MS` | integer | `10000` | WebSocket window, in milliseconds. | @@ -382,11 +435,14 @@ Live values, and where each one came from, are on the | `GAMEND_RETENTION_ABANDONED_PARTY_MINUTES` | integer | `15` | Disband parties nobody has been seen in for N minutes. 0 disables. | | `GAMEND_RETENTION_ACTIVITY_DAYS` | integer | `0` | Delete per-user daily activity rows (DAU / D1-D7-D30 source) older than N days. 0 keeps forever. Below 60 the admin retention cohorts go blank. | | `GAMEND_RETENTION_ANONYMOUS_USERS_DAYS` | integer | `90` | Delete device-only accounts inactive for N days. 0 keeps forever. These accounts cost one unauthenticated request to create, so they are the tier that actually needs a sweep. | +| `GAMEND_RETENTION_BATCH_SIZE` | integer | `500` | Rows deleted per statement. Lower it if a sweep stalls gameplay writes on SQLite, where each statement holds the write lock. | | `GAMEND_RETENTION_CHAT_MESSAGES_DAYS` | integer | `0` | Delete chat messages older than N days. 0 keeps forever. | | `GAMEND_RETENTION_INACTIVE_USERS_DAYS` | integer | `0` | Delete accounts with a real identity after N days of inactivity. 0 (the default) keeps forever - deleting a player who comes back is worse than the storage. 730 matches what Google and Microsoft use if you turn it on. | | `GAMEND_RETENTION_INACTIVE_USERS_WARN_DAYS` | integer | `30` | Email a warning this many days before an inactive account is deleted. 0 deletes with no warning. Accounts with no email address cannot be warned. | +| `GAMEND_RETENTION_INTERVAL_HOURS` | integer | `6` | Hours between full retention sweeps. The first runs five minutes after boot. | | `GAMEND_RETENTION_INVITES_DAYS` | integer | `30` | Delete resolved invites and join requests N days after resolution. | | `GAMEND_RETENTION_LEDGER_DAYS` | integer | `0` | Delete wallet/inventory ledger entries older than N days. 0 keeps forever. | +| `GAMEND_RETENTION_LIVE_INTERVAL_SECONDS` | integer | `60` | Seconds between sweeps of the classes that free live state: offline lobby and party seats, abandoned parties, abandoned lobbies. 0 leaves them to the full sweep. | | `GAMEND_RETENTION_LOBBY_SNAPSHOTS_DAYS` | integer | `30` | Delete lobby snapshots, events and blobs older than N days. | | `GAMEND_RETENTION_LOBBY_SNAPSHOTS_FLAGGED_DAYS` | integer | `90` | Longer window for snapshots of runs flagged anomalous. | | `GAMEND_RETENTION_MATCHMAKING_TICKETS_HOURS` | integer | `24` | Delete matchmaking tickets older than N hours, in any status. | @@ -409,7 +465,10 @@ Live values, and where each one came from, are on the | Variable | Type | Default | Notes | |---|---|---|---| | `GAMEND_HTTP_ALLOWED_ORIGINS` | list | - | Browser CORS/WebSocket origin allowlist. Empty allows any origin. Prefix an entry with `regex:` for a pattern. | +| `GAMEND_HTTP_CLIENT_RETRIES` | integer | `1` | Retries of a failed GET to a provider. POSTs are never retried. 0 disables. | +| `GAMEND_HTTP_CLIENT_TIMEOUT_MS` | integer | `10000` | Per-try timeout for calls to payment, OAuth and avatar providers, in milliseconds: connecting, and waiting for the response. | | `GAMEND_HTTP_HOST` | string | `"localhost"` | Public hostname, used to build URLs and OAuth redirect URIs. | +| `GAMEND_HTTP_MAX_BODY_BYTES` | integer | `1048576` | Largest request body the server reads (JSON, form or multipart), in bytes. Raise it with any GAMEND_LIMITS_* size above 1 MB, or requests that size are refused first. Local-backend uploads are capped by GAMEND_LIMITS_MAX_UPLOAD_BYTES instead. | | `GAMEND_HTTP_PORT` | integer | `4000` | TCP port the HTTP listener binds. | | `GAMEND_HTTP_SCHEME` | string | - | http or https. Defaults to http for localhost, https otherwise. | | `GAMEND_HTTP_SERVER` | boolean | `false` | Start the HTTP listener. Only needed when running as a release. | @@ -427,6 +486,8 @@ Live values, and where each one came from, are on the | `GAMEND_STORAGE_PUBLIC_URL` | string | - | CDN or base URL serving stored objects, whichever backend is behind it. | | `GAMEND_STORAGE_REGION` | string | `"auto"` | Region, or "auto" for services that do not use one (R2, MinIO). | | `GAMEND_STORAGE_SECRET_ACCESS_KEY` | string | - | **Required in production when `GAMEND_STORAGE_ADAPTER` is `s3`.** Secret - never log or commit it. | +| `GAMEND_STORAGE_SIGNED_URL_SECONDS` | integer | `3600` | Lifetime of the signed link /storage/ redirects to, for an S3 bucket with no public_url. S3 caps it at 604800 (7 days). | +| `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` | integer | `600` | How long an upload ticket stays valid, in seconds. Raise it for large uploads over slow connections. | ## TLS & certificates @@ -439,3 +500,20 @@ Live values, and where each one came from, are on the | `GAMEND_TLS_KEYFILE` | string | - | Path to privkey.pem. Warns if unset once `GAMEND_TLS_CERTFILE` is set. | | `GAMEND_TLS_PORT` | integer | `443` | HTTPS listen port. | + +## Tournaments + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS` | integer | `30` | Seconds between tournament ticks: state transitions, match-ready, deadline sweeps and recurrence. A round can start or time out up to this late. | + + +## WebRTC + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `GAMEND_WEBRTC_STUN_URLS` | list | `stun:stun.l.google.com:19302` | Comma-separated STUN server URLs. Empty uses none. | +| `GAMEND_WEBRTC_TURN_CREDENTIAL` | string | - | Credential for the TURN servers. Warns if unset once `GAMEND_WEBRTC_TURN_USERNAME` is set. Secret - never log or commit it. | +| `GAMEND_WEBRTC_TURN_URLS` | list | - | Comma-separated TURN server URLs (turn:host:3478, turns:host:5349). Empty uses none. Only needed when the server itself is behind NAT or UDP is filtered. | +| `GAMEND_WEBRTC_TURN_USERNAME` | string | - | Username for the TURN servers. Warns if unset once `GAMEND_WEBRTC_TURN_CREDENTIAL` is set. | + diff --git a/sdk/lib/gamend/accounts.ex b/sdk/lib/gamend/accounts.ex index f2922e44b..3084cec10 100644 --- a/sdk/lib/gamend/accounts.ex +++ b/sdk/lib/gamend/accounts.ex @@ -47,6 +47,36 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + Checks an email and password, counting failures per address + (`Gamend.Accounts.LoginLockouts`). + + `{:error, {:locked, seconds}}` when the address is locked, before the + password is looked at, and for the failure that locks it. + + """ + @spec authenticate_by_password(String.t(), String.t()) :: + {:ok, Gamend.Accounts.User.t()} + | {:error, :invalid_credentials | {:locked, pos_integer()}} + def authenticate_by_password(_email, _password) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + {:ok, + %Gamend.Accounts.User{ + id: 0, + email: "", + display_name: nil, + metadata: %{}, + is_admin: false, + inserted_at: ~U[1970-01-01 00:00:00Z], + updated_at: ~U[1970-01-01 00:00:00Z] + }} + + _ -> + raise "Gamend.Accounts.authenticate_by_password/2 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Broadcast a `friend_updated` event to all accepted friends. @@ -137,6 +167,31 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + Keep an account that was scheduled for deletion. A no-op for one that was not. + + """ + @spec cancel_deletion(Gamend.Accounts.User.t()) :: + {:ok, Gamend.Accounts.User.t()} | {:error, Ecto.Changeset.t()} + def cancel_deletion(_user) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + {:ok, + %Gamend.Accounts.User{ + id: 0, + email: "", + display_name: nil, + metadata: %{}, + is_admin: false, + inserted_at: ~U[1970-01-01 00:00:00Z], + updated_at: ~U[1970-01-01 00:00:00Z] + }} + + _ -> + raise "Gamend.Accounts.cancel_deletion/1 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Returns an `%Ecto.Changeset{}` for changing the user display_name. @@ -665,6 +720,34 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + Days a player's own deletion waits (`auth.deletion_grace_days`); 0 deletes at once. + """ + @spec deletion_grace_days() :: non_neg_integer() + def deletion_grace_days() do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + 0 + + _ -> + raise "Gamend.Accounts.deletion_grace_days/0 is a stub - only available at runtime on Gamend" + end + end + + @doc ~S""" + Whether `user` is waiting out a deletion grace period. + """ + @spec deletion_scheduled?(Gamend.Accounts.User.t() | nil) :: boolean() + def deletion_scheduled?(_user) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + :erlang.phash2(make_ref(), 2) == 0 + + _ -> + raise "Gamend.Accounts.deletion_scheduled?/1 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Delivers the magic link login instructions to the given user. @@ -781,6 +864,20 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + Accounts whose deletion date has passed. For `Gamend.Retention`. + """ + @spec due_deletions_query() :: Ecto.Query.t() + def due_deletions_query() do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + nil + + _ -> + raise "Gamend.Accounts.due_deletions_query/0 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Finds a user by Apple ID or creates a new user from OAuth data. @@ -1214,7 +1311,8 @@ defmodule Gamend.Accounts do end @doc ~S""" - Gets a user by email and password. + Gets a user by email and password. `nil` for a wrong password, and for an + address locked by too many failures (`authenticate_by_password/2` says which). ## Examples @@ -1758,13 +1856,13 @@ defmodule Gamend.Accounts do end @doc ~S""" - Register a user and send the confirmation email inside a DB transaction. + Register a user and queue its confirmation email. - The function accepts a `confirmation_url_fun` which must be a function of arity 1 - that receives the encoded token and returns the confirmation URL string. - - If sending the confirmation email fails the transaction is rolled back and - `{:error, reason}` is returned. On success it returns `{:ok, user}`. + `confirmation_url_fun` maps an encoded token to the confirmation URL. The + email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), + enqueued in the transaction that inserts the user: the call returns once + both are committed, without waiting on SMTP, and a failed send is retried + there. The first user becomes the admin and gets no email. """ @spec register_user_and_deliver(Gamend.Types.user_registration_attrs(), (String.t() -> @@ -1790,13 +1888,13 @@ defmodule Gamend.Accounts do end @doc ~S""" - Register a user and send the confirmation email inside a DB transaction. - - The function accepts a `confirmation_url_fun` which must be a function of arity 1 - that receives the encoded token and returns the confirmation URL string. + Register a user and queue its confirmation email. - If sending the confirmation email fails the transaction is rolled back and - `{:error, reason}` is returned. On success it returns `{:ok, user}`. + `confirmation_url_fun` maps an encoded token to the confirmation URL. The + email goes out from the `mailers` queue (`Gamend.Accounts.ConfirmationMailer`), + enqueued in the transaction that inserts the user: the call returns once + both are committed, without waiting on SMTP, and a failed send is retried + there. The first user becomes the admin and gets no email. """ @spec register_user_and_deliver( @@ -1824,7 +1922,7 @@ defmodule Gamend.Accounts do end @doc ~S""" - Register a user with an email and a password and send the confirmation + Register a user with an email and a password and queue the confirmation email, as `register_user_and_deliver/3` does for the browser form: how a game client signs up (`POST /api/v1/register`). @@ -1857,6 +1955,34 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + A player deleting their own account. + + With `auth.deletion_grace_days` at 0 the account is deleted now, by + `delete_user/1`. Otherwise it is scheduled that many days out and signed out + everywhere (every session, access, refresh and personal API token), and + `Gamend.Retention` deletes it on the day unless its owner signs in on the + website first (`cancel_deletion/1`). An account already scheduled keeps its + date. The expired session tokens come back so the caller can disconnect + their LiveViews. + + Admin deletions and the retention sweeps call `delete_user/1` and never wait. + + """ + @spec request_deletion(Gamend.Accounts.User.t()) :: + {:ok, :deleted} + | {:ok, {:scheduled, Gamend.Accounts.User.t(), [Gamend.Accounts.UserToken.t()]}} + | {:error, Ecto.Changeset.t()} + def request_deletion(_user) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + nil + + _ -> + raise "Gamend.Accounts.request_deletion/1 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Whether new accounts require manual admin activation before they can log in. @@ -2067,8 +2193,9 @@ defmodule Gamend.Accounts do @doc ~S""" Checks whether the user is in sudo mode. - The user is in sudo mode when the last authentication was done no further - than 20 minutes ago. The limit can be given as second argument in minutes. + With one argument, the window is the one a sudo form is submitted in: + `sudo_mode_minutes/0` plus ten minutes to fill the form in. The limit can be + given as second argument in minutes (negative, as an offset from now). """ @spec sudo_mode?(Gamend.Accounts.User.t()) :: boolean() @@ -2082,13 +2209,7 @@ defmodule Gamend.Accounts do end end - @doc ~S""" - Checks whether the user is in sudo mode. - - The user is in sudo mode when the last authentication was done no further - than 20 minutes ago. The limit can be given as second argument in minutes. - - """ + @doc false @spec sudo_mode?(Gamend.Accounts.User.t(), integer()) :: boolean() def sudo_mode?(_user, _minutes) do case Application.get_env(:gamend_sdk, :stub_mode, :raise) do @@ -2100,6 +2221,20 @@ defmodule Gamend.Accounts do end end + @doc ~S""" + How recently a user must have signed in to open a sudo page (`auth.sudo_mode_minutes`). + """ + @spec sudo_mode_minutes() :: pos_integer() + def sudo_mode_minutes() do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + 0 + + _ -> + raise "Gamend.Accounts.sudo_mode_minutes/0 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Updates `last_seen_at` to now for the given user. Fire-and-forget — errors are ignored. Call on login (session or JWT) to track activity. Also records the UTC day diff --git a/sdk/lib/gamend/hooks.ex b/sdk/lib/gamend/hooks.ex index 5da24dba4..9102b710a 100644 --- a/sdk/lib/gamend/hooks.ex +++ b/sdk/lib/gamend/hooks.ex @@ -593,10 +593,13 @@ defmodule Gamend.Hooks do end end """ - defmacro __using__(_opts) do - quote do - @behaviour Gamend.Hooks + # The defaults `__using__/1` injects, as quoted code split across three + # attributes: one quote block this long is past credo's LongQuoteBlocks, and + # a function holding one counts every branch of the code it quotes. + + default_callbacks = + quote do @impl true def after_startup, do: :ok @@ -651,6 +654,9 @@ defmodule Gamend.Hooks do @impl true def before_group_create(_user, attrs), do: {:ok, attrs} + @impl true + def before_group_join(user, group, opts), do: {:ok, {user, group, opts}} + @impl true def after_group_create(_group), do: :ok @@ -683,7 +689,8 @@ defmodule Gamend.Hooks do def before_party_join(user, party), do: {:ok, {user, party}} @impl true - def before_party_kick(admin, target, party), do: {:ok, {admin, target, party}} + def before_party_kick(admin, target, party), + do: {:ok, {admin, target, party}} @impl true def before_purchase(_user, product), do: {:ok, product} @@ -717,7 +724,12 @@ defmodule Gamend.Hooks do @impl true def after_party_leave(_user, _party_id), do: :ok + end + @default_callbacks default_callbacks + + more_default_callbacks = + quote do @impl true def after_party_kick(_target, _leader, _party), do: :ok @@ -779,7 +791,8 @@ defmodule Gamend.Hooks do def after_lobby_state_changed(_lobby, _from, _to), do: :ok @impl true - def before_lobby_kick(host, target, lobby), do: {:ok, {host, target, lobby}} + def before_lobby_kick(host, target, lobby), + do: {:ok, {host, target, lobby}} @impl true def after_lobby_kick(_host, _target, _lobby), do: :ok @@ -815,7 +828,8 @@ defmodule Gamend.Hooks do def after_matchmaking_matched(_tickets, _lobby_id), do: :ok @impl true - def before_tournament_register(_user, tournament), do: {:ok, tournament} + def before_tournament_register(_user, tournament), + do: {:ok, tournament} @impl true def after_tournament_register(_user, _tournament), do: :ok @@ -837,7 +851,12 @@ defmodule Gamend.Hooks do @impl true def after_tournament_finished(_tournament, _standings), do: :ok + end + + @more_default_callbacks more_default_callbacks + overridable_callbacks = + quote do defoverridable after_startup: 0, before_stop: 0, before_group_delete: 1, @@ -864,6 +883,7 @@ defmodule Gamend.Hooks do before_lobby_create: 1, after_lobby_create: 1, before_group_create: 2, + before_group_join: 3, after_group_create: 1, before_group_update: 2, after_group_updated: 1, @@ -919,6 +939,16 @@ defmodule Gamend.Hooks do after_tournament_match_resolved: 1, after_tournament_finished: 2 end + + @overridable_callbacks overridable_callbacks + defmacro __using__(_opts) do + quote do + @behaviour Gamend.Hooks + + unquote(@default_callbacks) + unquote(@more_default_callbacks) + unquote(@overridable_callbacks) + end end @doc """ diff --git a/sdk/lib/gamend/kv.ex b/sdk/lib/gamend/kv.ex index 01ecc7960..519f27738 100644 --- a/sdk/lib/gamend/kv.ex +++ b/sdk/lib/gamend/kv.ex @@ -141,6 +141,26 @@ defmodule Gamend.KV do end end + @doc ~S""" + Delete every entry scoped to a lobby, in one statement: for deleting the lobby. + + The per-entry cache invalidations and `kv_deleted` broadcasts wait for the + enclosing transaction to commit (`Gamend.AfterCommit`). One `delete/2` per + entry cost a statement and two cache round-trips each while the caller held + the lobby's lock. Returns the number of entries deleted. + + """ + @spec delete_lobby_entries(Ecto.UUID.t()) :: non_neg_integer() + def delete_lobby_entries(_lobby_id) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + 0 + + _ -> + raise "Gamend.KV.delete_lobby_entries/1 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Delete every entry a user holds inside one lobby. diff --git a/sdk/lib/gamend/lock.ex b/sdk/lib/gamend/lock.ex index f5c5bb098..8ca6e4c5e 100644 --- a/sdk/lib/gamend/lock.ex +++ b/sdk/lib/gamend/lock.ex @@ -14,7 +14,9 @@ defmodule Gamend.Lock do Serialized on **both** adapters, per key, by different mechanisms: - * **PostgreSQL** — `pg_advisory_xact_lock` inside the transaction. + * **PostgreSQL** — `pg_advisory_xact_lock` inside the transaction, taken + after a node-local mutex so a node's own waiters queue in the BEAM rather + than each holding a pooled connection while blocked. * **SQLite** — `Gamend.Lock.Local`, a `:global` mutex taken *around* the transaction, since SQLite has no advisory locks and its single-writer rule covers neither a read-modify-write across statements nor a critical @@ -30,6 +32,15 @@ defmodule Gamend.Lock do waiting on a mutex whose holder wants that lock deadlocks — so it runs under the outer transaction and relies on the outer lock. + ## Effects + + `serialize/3` is a `Gamend.AfterCommit` scope: broadcasts, hook tasks + (`Gamend.Async.run/1`) and anything else deferred inside `fun` run once the + transaction has committed and the lock is released. Keep `fun` to database + work. Anything slow in it (hashing a password, a plugin's `before_*` hook, + an HTTP call) holds the lock and, on SQLite, the only connection: do it + before, and re-check inside only what the lock protects. + ## Prefer an atomic write Prefer an atomic write where one exists: `Economy.spend/4` does @@ -77,6 +88,38 @@ defmodule Gamend.Lock do The actual implementation runs on the Gamend. """ + @doc ~S""" + Runs `fun` holding the `(namespace, resource_id)` lock but not inside a + transaction: for a job that must run once cluster-wide and makes its own + short writes, such as `Gamend.Tournaments.tick/1`. Each write inside commits, + and runs its deferred effects, by itself. + + A transaction held across the whole job kept every row it touched locked + until the end and, on SQLite, the database's single write lock with them. + + * **SQLite** — the mutex alone gives the once-only guarantee: SQLite is one + node. + * **Postgres** — a session-level advisory lock on a checked-out + connection, which `fun` then runs on, committing as it goes. It holds one + pooled connection for the job, not a transaction. If the caller dies, + DBConnection closes that connection, which releases the lock. + + Returns `{:ok, result}`. Inside an open transaction it is `serialize/3`. + + """ + @spec exclusive(atom() | String.t(), String.t(), (-> result)) :: + {:ok, result} | {:error, term()} + when result: term() + def exclusive(_namespace, _resource_id, _fun) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + nil + + _ -> + raise "Gamend.Lock.exclusive/3 is a stub - only available at runtime on Gamend" + end + end + @doc ~S""" Execute `fun` inside a transaction with an advisory lock on `(namespace, resource_id)`. diff --git a/sdk/lib/gamend/payments.ex b/sdk/lib/gamend/payments.ex index 3fd4e7bf4..3b181c35d 100644 --- a/sdk/lib/gamend/payments.ex +++ b/sdk/lib/gamend/payments.ex @@ -201,6 +201,24 @@ defmodule Gamend.Payments do end end + @doc ~S""" + Open Stripe's customer portal for this account: cancel, change card, download + invoices. `{:error, :no_stripe_customer}` when the account never paid through + Stripe Checkout. + + """ + @spec create_stripe_billing_portal(Gamend.Accounts.User.t(), String.t()) :: + {:ok, String.t()} | {:error, term()} + def create_stripe_billing_portal(_user, _return_url) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + {:ok, nil} + + _ -> + raise "Gamend.Payments.create_stripe_billing_portal/2 is a stub - only available at runtime on Gamend" + end + end + @doc false @spec create_stripe_checkout(Gamend.Accounts.User.t(), map()) :: {:ok, @@ -648,6 +666,25 @@ defmodule Gamend.Payments do end end + @doc ~S""" + The Stripe customer this account has paid as, or nil: the newest Stripe + purchase whose stored checkout session names one. Stripe creates the customer + at checkout (subscriptions always; one-off payments since + `customer_creation: "always"`), and `checkout.session.completed` stores the + session on the purchase. + + """ + @spec stripe_customer_id(Gamend.Accounts.User.t()) :: String.t() | nil + def stripe_customer_id(_user) do + case Application.get_env(:gamend_sdk, :stub_mode, :raise) do + :placeholder -> + nil + + _ -> + raise "Gamend.Payments.stripe_customer_id/1 is a stub - only available at runtime on Gamend" + end + end + @doc false @spec update_product(Gamend.Payments.Product.t(), map()) :: {:ok, Gamend.Payments.Product.t()} | {:error, Ecto.Changeset.t()} diff --git a/sdk_tools/lib/gamend/gdscript/api.ex b/sdk_tools/lib/gamend/gdscript/api.ex index 21de93cfc..2c6854c99 100644 --- a/sdk_tools/lib/gamend/gdscript/api.ex +++ b/sdk_tools/lib/gamend/gdscript/api.ex @@ -8,6 +8,7 @@ defmodule Gamend.GDScript.API do "deliver_login_instructions" => [2], "get_user_by_magic_link_token" => [1], "search_users" => [1, 2], + "due_deletions_query" => [0], "update_user_display_name" => [2], "display_label" => [1], "require_account_activation?" => [0], @@ -54,6 +55,7 @@ defmodule Gamend.GDScript.API do "revoke_all_tokens" => [1], "player_stats" => [0], "register_user_with_password_and_deliver" => [2, 3], + "cancel_deletion" => [1], "broadcast_member_update" => [1], "sudo_mode?" => [1, 2], "user_activated?" => [1], @@ -70,12 +72,15 @@ defmodule Gamend.GDScript.API do "unlink_provider" => [2], "merge_metadata" => [2], "set_user_online" => [1], + "deletion_grace_days" => [0], "get_user_by_github_id" => [1], "find_or_create_from_discord" => [1], "touch_last_seen" => [1], "attach_device_to_user" => [2], "change_user_display_name" => [1, 2], "get_user_by_apple_id" => [1], + "sudo_mode_minutes" => [0], + "deletion_scheduled?" => [1], "delete_user_session_token" => [1], "confirm_user" => [1], "get_user_by_email" => [1], @@ -83,9 +88,11 @@ defmodule Gamend.GDScript.API do "update_user_avatar" => [2], "get_user_by_session_token" => [1], "get_user_by_username" => [1], + "authenticate_by_password" => [2], "get_user_by_google_id" => [1], "change_user_email" => [1, 2, 3], "register_user_and_deliver" => [2, 3], + "request_deletion" => [1], "find_or_create_from_facebook" => [1], "serialize_user_payload" => [1], "can_upload_avatar?" => [1], @@ -167,7 +174,7 @@ defmodule Gamend.GDScript.API do "decr" => [1, 2, 3, 4], "fetch_or_store!" => [2, 3, 4], "incr!" => [1, 2, 3, 4], - "ttl" => [1, 2, 3], + "ttl" => [0, 1, 2, 3], "stream" => [0, 1, 2, 3], "ttl!" => [1, 2, 3], "fetch!" => [1, 2, 3], @@ -373,6 +380,7 @@ defmodule Gamend.GDScript.API do "create_entry" => [1], "delete" => [1, 2], "delete_entry" => [1], + "delete_lobby_entries" => [1], "delete_user_lobby_entries" => [2], "get" => [1, 2], "get_entry" => [1], @@ -452,7 +460,7 @@ defmodule Gamend.GDScript.API do "webrtc_enabled_lobby_ids" => [0], "count_locked_lobbies" => [0] }, - "Lock" => %{"serialize" => [3]}, + "Lock" => %{"exclusive" => [3], "serialize" => [3]}, "Notifications" => %{ "admin_create_notification" => [3], "admin_delete_notification" => [1], @@ -511,6 +519,7 @@ defmodule Gamend.GDScript.API do "count_all_party_members" => [0] }, "Payments" => %{ + "stripe_customer_id" => [1], "get_purchase_by_provider_original_transaction" => [2], "update_provider_product" => [2], "get_product_by_sku" => [1], @@ -551,6 +560,7 @@ defmodule Gamend.GDScript.API do "stripe_config_status" => [0], "count_entitlements" => [0, 1], "cancel_stripe_subscription_at_period_end" => [2], + "create_stripe_billing_portal" => [2], "finalize_steam_purchase" => [2], "list_admin_provider_products" => [0, 1], "list_reconciliation_cursors" => [0, 1], From b53c7824e0c5c2e659780d8eec036633b16df2ad Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 17:46:57 +0200 Subject: [PATCH 2/8] Rename login_lockouts.locked_until to unlocks_at, window the due-deletions sweep The API lint (R3) wants an instant's name to end in _at. The retention query for accounts past their deletion date now takes its batch limit inside from/2, like the other retention queries (R13). The migration is edited in place: the branch is unmerged. A database that already ran 20260925130000 needs those three migrations rolled back and run again. --- .../lib/gamend/accounts/login_lockout.ex | 6 +++--- .../lib/gamend/accounts/login_lockouts.ex | 14 +++++++------- apps/gamend_core/lib/gamend/retention.ex | 3 +-- .../20260925130000_create_login_lockouts.exs | 2 +- .../test/gamend/accounts/login_lockouts_test.exs | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/gamend_core/lib/gamend/accounts/login_lockout.ex b/apps/gamend_core/lib/gamend/accounts/login_lockout.ex index fa914cb75..7e776fdd9 100644 --- a/apps/gamend_core/lib/gamend/accounts/login_lockout.ex +++ b/apps/gamend_core/lib/gamend/accounts/login_lockout.ex @@ -8,7 +8,7 @@ defmodule Gamend.Accounts.LoginLockout do itself is never stored - `failures` – failed attempts in the current window - `window_started_at` – when the current window's first failure happened - - `locked_until` – nil, or when the lock the failures set runs out + - `unlocks_at` – nil, or when the lock the failures set runs out See `Gamend.Accounts.LoginLockouts`. """ @@ -18,7 +18,7 @@ defmodule Gamend.Accounts.LoginLockout do field :key_hash, :binary, redact: true field :failures, :integer, default: 0 field :window_started_at, :utc_datetime - field :locked_until, :utc_datetime + field :unlocks_at, :utc_datetime timestamps(type: :utc_datetime) end @@ -29,7 +29,7 @@ defmodule Gamend.Accounts.LoginLockout do key_hash: binary() | nil, failures: non_neg_integer(), window_started_at: DateTime.t() | nil, - locked_until: DateTime.t() | nil, + unlocks_at: DateTime.t() | nil, inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } diff --git a/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex b/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex index cbbd06cfa..033e85f63 100644 --- a/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex +++ b/apps/gamend_core/lib/gamend/accounts/login_lockouts.ex @@ -38,15 +38,15 @@ defmodule Gamend.Accounts.LoginLockouts do if enabled?() do now = DateTime.utc_now(:second) - locked_until = + unlocks_at = Repo.one( from(l in LoginLockout, - where: l.key_hash == ^key(email) and l.locked_until > ^now, - select: l.locked_until + where: l.key_hash == ^key(email) and l.unlocks_at > ^now, + select: l.unlocks_at ) ) - if locked_until, do: {:locked, max(DateTime.diff(locked_until, now), 1)}, else: :ok + if unlocks_at, do: {:locked, max(DateTime.diff(unlocks_at, now), 1)}, else: :ok else :ok end @@ -111,10 +111,10 @@ defmodule Gamend.Accounts.LoginLockouts do # out the address gets the full number of attempts again. {changes, result} = if failures >= attempts() do - locked_until = DateTime.add(now, lockout_minutes(), :minute) + unlocks_at = DateTime.add(now, lockout_minutes(), :minute) - {%{failures: 0, window_started_at: now, locked_until: locked_until}, - {:locked, DateTime.diff(locked_until, now)}} + {%{failures: 0, window_started_at: now, unlocks_at: unlocks_at}, + {:locked, DateTime.diff(unlocks_at, now)}} else {%{failures: failures, window_started_at: started}, :ok} end diff --git a/apps/gamend_core/lib/gamend/retention.ex b/apps/gamend_core/lib/gamend/retention.ex index 685dcddcc..7c9b990fe 100644 --- a/apps/gamend_core/lib/gamend/retention.ex +++ b/apps/gamend_core/lib/gamend/retention.ex @@ -622,8 +622,7 @@ defmodule Gamend.Retention do # Accounts their owners deleted, once `auth.deletion_grace_days` has run out. # No exemptions: the owner asked. A batch a sweep, like the other user sweeps. defp delete_due_accounts do - Accounts.due_deletions_query() - |> limit(^batch()) + from(u in Accounts.due_deletions_query(), limit: ^batch()) |> Repo.all() |> delete_users() end diff --git a/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs b/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs index 868dc46ab..cee605a52 100644 --- a/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs +++ b/apps/gamend_core/priv/repo/migrations/20260925130000_create_login_lockouts.exs @@ -16,7 +16,7 @@ defmodule Gamend.Repo.Migrations.CreateLoginLockouts do add :key_hash, :binary, null: false add :failures, :integer, null: false, default: 0 add :window_started_at, :utc_datetime, null: false - add :locked_until, :utc_datetime + add :unlocks_at, :utc_datetime timestamps(type: :utc_datetime) end diff --git a/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs b/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs index 76754a595..a6dca5370 100644 --- a/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs +++ b/apps/gamend_core/test/gamend/accounts/login_lockouts_test.exs @@ -73,7 +73,7 @@ defmodule Gamend.Accounts.LoginLockoutsTest do assert {:error, {:locked, _}} = fail(user.email) past = DateTime.add(DateTime.utc_now(:second), -1, :second) - Repo.update_all(LoginLockout, set: [locked_until: past]) + Repo.update_all(LoginLockout, set: [unlocks_at: past]) assert {:ok, _} = Accounts.authenticate_by_password(user.email, @password) end From 988ce7861192a58f641dd66965e2a89b67e2a311 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 20:49:49 +0200 Subject: [PATCH 3/8] Game socket timeout and frame cap are settings; test the remaining settings GAMEND_REALTIME_SOCKET_TIMEOUT_MS (300000) and GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES (131072). socket/3 fixes a transport's options when the endpoint compiles, so /socket is declared with no transport (Phoenix still supervises it) and the endpoint's first plug serves /socket/websocket with options built at runtime, through the same Phoenix.Transports.WebSocket plug the macro would call. GameSocketTest upgrades through the real endpoint and checks the options, so a Phoenix change there fails the suite. Tests for what was left uncovered: - tournament tick and retention live cycle reschedule at their settings (handle_info in the test process, a one-second interval observed as the message arriving); a live interval of 0 schedules nothing; the full sweep interval as the value its timer is set from - retention deletes batch_size rows per statement, counted through Repo telemetry - Google ID-token checks, OAuth code exchanges and Steam payment calls go through Gamend.HTTP, which now merges a req_options test hook - provider sign-in refuses an account scheduled for deletion over the polling flow and the ID-token API, and keeps it on the website --- .env.example | 8 + CHANGELOG.md | 3 +- apps/gamend_core/lib/gamend/http.ex | 15 +- apps/gamend_core/lib/gamend/retention.ex | 7 +- .../test/gamend/scheduling_settings_test.exs | 158 ++++++++++++++++++ apps/gamend_web/lib/gamend_web/endpoint.ex | 51 +++++- apps/gamend_web/lib/gamend_web/realtime.ex | 16 ++ .../controllers/auth_controller_api_test.exs | 40 +++++ .../controllers/auth_controller_test.exs | 55 ++++++ .../test/gamend_web/game_socket_test.exs | 66 ++++++++ priv/docs/30-clients/30-realtime.md | 1 + priv/docs/60-operations/40-settings.md | 4 +- 12 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 apps/gamend_core/test/gamend/scheduling_settings_test.exs create mode 100644 apps/gamend_web/test/gamend_web/game_socket_test.exs diff --git a/.env.example b/.env.example index ddfb7a9e8..6c56ee24f 100644 --- a/.env.example +++ b/.env.example @@ -928,6 +928,14 @@ # window. # GAMEND_REALTIME_SOCKET_BUFFER_KB=0 +# Largest single WebSocket frame a game client may send, in bytes. +# GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES=131072 + +# How long a game socket may stay silent before it is closed, in ms. Longer +# keeps alt-tabbed players; shorter frees half-open sockets (and their +# seats) sooner. +# GAMEND_REALTIME_SOCKET_TIMEOUT_MS=300000 + # ────────────────────────────────────────────────────────────────────────── # Retention # ────────────────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ac29ce4..9cc0b4148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,11 @@ - `GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS` (30). - WebRTC: `GAMEND_WEBRTC_STUN_URLS` and a TURN relay with `GAMEND_WEBRTC_TURN_URLS`, `_TURN_USERNAME`, `_TURN_CREDENTIAL`, for the server's own peer. - `GAMEND_HTTP_MAX_BODY_BYTES` (1048576): the largest request body parsed. `Plug.Parsers` is built at runtime for it. + - Game socket: `GAMEND_REALTIME_SOCKET_TIMEOUT_MS` (300000), how long a silent socket stays open, and `GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES` (131072). `socket/3` fixes a transport's options when the endpoint compiles, so `/socket` is now declared with no transport (Phoenix still supervises it) and the endpoint's first plug serves `/socket/websocket` with options built at runtime. It calls the same `Phoenix.Transports.WebSocket` plug the macro would; `GamendWeb.GameSocketTest` upgrades through the real endpoint, so a Phoenix change there fails the suite. - Peer-to-peer signaling rate limits: `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT` / `_WINDOW_MS` (300 / 10s) and `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT` / `_WINDOW_MS` (150 / 30s). The admin rate-limiting page reads them. - `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS` (2) and `_MAX_PLAYERS` (5), for a ticket that leaves its size out. -- [changed] **Config keys replaced by those settings.** The undeclared app-env keys `:hooks_call_timeout`, `:hooks_call_timeout_in_transaction` and `:slow_hook_threshold_ms` under `:gamend_core` are gone: set `call_timeout_ms`, `call_timeout_in_transaction_ms` and `slow_threshold_ms` on `Gamend.Hooks.PluginManager`. The signaling keys `:signaling_ws_window` and `:signaling_ice_window` are now `:signaling_ws_window_ms` and `:signaling_ice_window_ms`. `config/host_config.exs` no longer sets `ice_servers` for `:webrtc`; a host that sets it still overrides the WebRTC settings. The websocket's idle timeout and frame size stay compile-time: Phoenix fixes a socket's options when the endpoint compiles. +- [changed] **Config keys replaced by those settings.** The undeclared app-env keys `:hooks_call_timeout`, `:hooks_call_timeout_in_transaction` and `:slow_hook_threshold_ms` under `:gamend_core` are gone: set `call_timeout_ms`, `call_timeout_in_transaction_ms` and `slow_threshold_ms` on `Gamend.Hooks.PluginManager`. The signaling keys `:signaling_ws_window` and `:signaling_ice_window` are now `:signaling_ws_window_ms` and `:signaling_ice_window_ms`. `config/host_config.exs` no longer sets `ice_servers` for `:webrtc`; a host that sets it still overrides the WebRTC settings. - [added] **Token lifetimes are settings.** `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES` (default 15) and `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS` (default 30) set how long API access and refresh tokens last. Both were literals: the access TTL in the Guardian config (in `HostRuntime` for prod, and again in `config/dev.exs` and `config/test.exs`), the refresh TTL at every call that signed one, and `expires_in: 900` in the login, refresh and OAuth answers. `GamendWeb.Auth.Guardian` now takes its `token_ttl` from `GamendWeb.Auth.Tokens.ttls/0`, so every token signed, anywhere, follows the settings, and `expires_in` is derived from the same value. A value below 1 counts as 1. The `ttl` key is gone from the Guardian config; a host that still sets one is ignored, because the per-type TTL wins over it. Tokens already issued keep the lifetime they were signed with. The Godot and C++ SDKs schedule their refresh from `expires_in` and need no change. diff --git a/apps/gamend_core/lib/gamend/http.ex b/apps/gamend_core/lib/gamend/http.ex index db26578d2..64b6d1248 100644 --- a/apps/gamend_core/lib/gamend/http.ex +++ b/apps/gamend_core/lib/gamend/http.ex @@ -40,15 +40,20 @@ defmodule Gamend.HTTP do @spec post(String.t() | URI.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, Exception.t()} def post(url, opts \\ []), do: Req.post(url, options(opts)) - @doc "The declared timeout and retry options, with `opts` over them." + @doc """ + The declared timeout and retry options, with `opts` over them. + + `config :gamend_core, Gamend.HTTP, req_options: [...]` is merged in too, so + a test can route every provider call through a `Req.Test` stub. Empty in prod. + """ @spec options(keyword()) :: keyword() def options(opts \\ []) do timeout = max(Gamend.Settings.get(__MODULE__, :client_timeout_ms), 1) retries = max(Gamend.Settings.get(__MODULE__, :client_retries), 0) + injected = Keyword.get(Application.get_env(:gamend_core, __MODULE__, []), :req_options, []) - Keyword.merge( - [receive_timeout: timeout, connect_options: [timeout: timeout], max_retries: retries], - opts - ) + [receive_timeout: timeout, connect_options: [timeout: timeout], max_retries: retries] + |> Keyword.merge(injected) + |> Keyword.merge(opts) end end diff --git a/apps/gamend_core/lib/gamend/retention.ex b/apps/gamend_core/lib/gamend/retention.ex index 7c9b990fe..2a0fbeb94 100644 --- a/apps/gamend_core/lib/gamend/retention.ex +++ b/apps/gamend_core/lib/gamend/retention.ex @@ -182,7 +182,7 @@ defmodule Gamend.Retention do @impl true def handle_info(:prune, _state) do state = sweep() - Process.send_after(self(), :prune, :timer.hours(max(config(:interval_hours), 1))) + Process.send_after(self(), :prune, sweep_interval_ms()) {:noreply, state} end @@ -196,6 +196,11 @@ defmodule Gamend.Retention do def handle_info(_msg, state), do: {:noreply, state} + @doc false + # Milliseconds between full sweeps: `interval_hours`, at least one. + @spec sweep_interval_ms() :: pos_integer() + def sweep_interval_ms, do: :timer.hours(max(config(:interval_hours), 1)) + # 0 folds the live classes back into the full sweep only. defp schedule_live do case config(:live_interval_seconds) do diff --git a/apps/gamend_core/test/gamend/scheduling_settings_test.exs b/apps/gamend_core/test/gamend/scheduling_settings_test.exs new file mode 100644 index 000000000..18c5f7335 --- /dev/null +++ b/apps/gamend_core/test/gamend/scheduling_settings_test.exs @@ -0,0 +1,158 @@ +defmodule Gamend.SchedulingSettingsTest do + @moduledoc """ + The periodic work whose cadence and batch size became settings, and the + provider modules that now call out through `Gamend.HTTP`. + + A GenServer's `handle_info/2` is called here in the test process, so the + timer it sets lands in this mailbox: a one-second interval is observed as + the message arriving. An interval of an hour or more (the full retention + sweep) cannot be waited out in a test, so that one is checked as the value + the timer is set from. + """ + # Settings are global Application config. + use Gamend.DataCase, async: false + + alias Gamend.Accounts + alias Gamend.Accounts.UserToken + alias Gamend.AccountsFixtures + alias Gamend.OAuth.Exchanger + alias Gamend.OAuth.GoogleIDToken + alias Gamend.Payments.Providers.Steam + alias Gamend.Retention + alias Gamend.SettingsHelpers + alias Gamend.Tournaments.Ticker + + defp put(app \\ :gamend_core, module, key, value) do + SettingsHelpers.put(app, module, key, value) + on_exit(fn -> SettingsHelpers.delete(app, module, key) end) + end + + describe "tournament ticker" do + test "ticks again after tick_interval_seconds" do + put(Ticker, :tick_interval_seconds, 1) + + assert {:noreply, _} = Ticker.handle_info(:tick, %{}) + refute_received :tick + assert_receive :tick, 1_500 + end + end + + describe "retention cadence" do + test "the live cycle comes round after live_interval_seconds" do + put(Retention, :live_interval_seconds, 1) + + assert {:noreply, _} = Retention.handle_info(:prune_live, %{}) + assert_receive :prune_live, 1_500 + end + + test "a live interval of 0 schedules nothing" do + put(Retention, :live_interval_seconds, 0) + + assert {:noreply, _} = Retention.handle_info(:prune_live, %{}) + refute_receive :prune_live, 1_200 + end + + test "the full sweep waits interval_hours, at least one" do + assert Retention.sweep_interval_ms() == :timer.hours(6) + + put(Retention, :interval_hours, 24) + assert Retention.sweep_interval_ms() == :timer.hours(24) + + put(Retention, :interval_hours, 0) + assert Retention.sweep_interval_ms() == :timer.hours(1) + end + end + + describe "retention batch size" do + # Counts the DELETE statements on `users_tokens` that `fun` issues. + defp token_deletes(fun) do + test = self() + handler = "retention-batch-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler, + [:gamend, :repo, :query], + fn _event, _measurements, %{query: sql}, _config -> + if sql =~ ~r/^DELETE FROM "users_tokens"/, do: send(test, :token_delete) + end, + nil + ) + + try do + result = fun.() + {result, count_received(:token_delete, 0)} + after + :telemetry.detach(handler) + end + end + + defp count_received(message, count) do + receive do + ^message -> count_received(message, count + 1) + after + 0 -> count + end + end + + test "rows are deleted batch_size at a time, and all of them" do + put(Retention, :batch_size, 2) + user = AccountsFixtures.user_fixture() + + for _ <- 1..5 do + token = Accounts.generate_user_session_token(user) + AccountsFixtures.offset_user_token(token, -30, :day) + end + + assert Repo.aggregate(UserToken.expired_query(), :count) == 5 + + # 2 + 2 + 1 + assert {%{expired_user_tokens: 5}, 3} = token_deletes(&Retention.prune_all/0) + assert Repo.aggregate(UserToken.expired_query(), :count) == 0 + end + end + + describe "provider calls go through Gamend.HTTP" do + setup do + put(Gamend.HTTP, :req_options, plug: {Req.Test, __MODULE__}) + :ok + end + + test "Google ID-token checks" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.host == "oauth2.googleapis.com" + Req.Test.json(conn, %{"sub" => "g1", "aud" => "web", "iss" => "accounts.google.com"}) + end) + + assert {:ok, %{"sub" => "g1"}} = + GoogleIDToken.verify("token", expected_auds: ["web"]) + end + + test "OAuth code exchanges" do + Req.Test.stub(__MODULE__, fn + %{method: "POST"} = conn -> Req.Test.json(conn, %{"access_token" => "at"}) + conn -> Req.Test.json(conn, %{"id" => "d1", "username" => "duser"}) + end) + + assert {:ok, %{"id" => "d1"}} = + Exchanger.exchange_discord_code( + "code", + "id", + "secret", + "https://x/cb" + ) + end + + test "payment provider calls" do + put(Gamend.OAuth.Providers, :steam_api_key, "key") + put(Gamend.Payments.Settings, :steam_app_id, "480") + + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path =~ "QueryTxn" + Req.Test.json(conn, %{"response" => %{"result" => 1, "params" => %{"orderid" => "7"}}}) + end) + + assert {:ok, %{"response" => %{"params" => %{"orderid" => "7"}}}} = + Steam.query_transaction(%{"order_id" => "7"}) + end + end +end diff --git a/apps/gamend_web/lib/gamend_web/endpoint.ex b/apps/gamend_web/lib/gamend_web/endpoint.ex index b19e09835..fa1c09bb7 100644 --- a/apps/gamend_web/lib/gamend_web/endpoint.ex +++ b/apps/gamend_web/lib/gamend_web/endpoint.ex @@ -1,6 +1,9 @@ defmodule GamendWeb.Endpoint do use Phoenix.Endpoint, otp_app: :gamend_web + alias Phoenix.Socket.Transport + alias Phoenix.Transports.WebSocket + @session_options [ store: :cookie, key: "_gamend_key", @@ -9,14 +12,12 @@ defmodule GamendWeb.Endpoint do secure: Application.compile_env(:gamend_web, :session_secure, false) ] - # timeout: the game client runs on requestAnimationFrame, which browsers - # stop entirely for background tabs — heartbeats pause and the default 60s - # would drop every alt-tabbed player. 5 minutes keeps the TCP-alive-but- - # silent socket open across short tab switches; hard disconnects still - # terminate immediately (this only defers reaping half-open connections). - socket "/socket", GamendWeb.UserSocket, - websocket: [log: false, compress: true, max_frame_size: 131_072, timeout: 300_000], - longpoll: false + # The game socket. Its idle timeout and frame cap are settings + # (`GamendWeb.Realtime`), but `socket/3` fixes a transport's options when the + # endpoint compiles. So it is declared with no transport, which keeps Phoenix + # supervising it, and `game_socket/2` below serves `/socket/websocket` with + # options built at runtime. + socket "/socket", GamendWeb.UserSocket, websocket: false, longpoll: false # `:user_agent` alongside the peer data: a page that adapts to the # visitor's platform — a download page highlighting their OS — reads it in @@ -31,6 +32,8 @@ defmodule GamendWeb.Endpoint do ], longpoll: [connect_info: [:user_agent, session: @session_options], log: false] + # First, where Phoenix's own socket dispatch runs. + plug :game_socket plug GamendWeb.Plugs.AcmeChallenge # After AcmeChallenge so certbot's HTTP-01 fetch is answered before any # redirect can touch it; before everything else so a plain-HTTP request @@ -106,6 +109,38 @@ defmodule GamendWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head + # What Phoenix generates for a `socket/3` websocket transport: the same + # config, loaded the same way, handed to the same plug. Built once per pair of + # values, as `socket/3` builds it once per compile. + defp game_socket(%Plug.Conn{path_info: ["socket", "websocket"]} = conn, _opts) do + conn + |> WebSocket.call({__MODULE__, GamendWeb.UserSocket, game_socket_opts()}) + |> halt() + end + + defp game_socket(conn, _opts), do: conn + + defp game_socket_opts do + timeout = max(Gamend.Settings.get(GamendWeb.Realtime, :socket_timeout_ms), 1_000) + max_frame = max(Gamend.Settings.get(GamendWeb.Realtime, :socket_max_frame_bytes), 1_024) + key = {__MODULE__, :game_socket, timeout, max_frame} + + case :persistent_term.get(key, nil) do + nil -> + opts = + Transport.load_config( + [log: false, compress: true, max_frame_size: max_frame, timeout: timeout], + WebSocket + ) + + :persistent_term.put(key, opts) + opts + + opts -> + opts + end + end + @parsers_opts [ parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], diff --git a/apps/gamend_web/lib/gamend_web/realtime.ex b/apps/gamend_web/lib/gamend_web/realtime.ex index 5fc539a16..10a9312e5 100644 --- a/apps/gamend_web/lib/gamend_web/realtime.ex +++ b/apps/gamend_web/lib/gamend_web/realtime.ex @@ -58,6 +58,22 @@ defmodule GamendWeb.Realtime do # So: leave it alone unless a node is holding tens of thousands of sockets and # memory is the binding constraint, and measure per-socket memory before and # after rather than assuming it worked. + # The default timeout exists for background tabs: the game client runs on + # requestAnimationFrame, which browsers stop for a hidden tab, so heartbeats + # pause and Phoenix's own 60s would drop every alt-tabbed player. Hard + # disconnects still end at once; this only defers reaping half-open sockets. + setting(:socket_timeout_ms, :integer, + default: 300_000, + doc: + "How long a game socket may stay silent before it is closed, in ms. Longer keeps " <> + "alt-tabbed players; shorter frees half-open sockets (and their seats) sooner." + ) + + setting(:socket_max_frame_bytes, :integer, + default: 131_072, + doc: "Largest single WebSocket frame a game client may send, in bytes." + ) + setting(:socket_buffer_kb, :integer, default: 0, doc: diff --git a/apps/gamend_web/test/gamend_web/controllers/auth_controller_api_test.exs b/apps/gamend_web/test/gamend_web/controllers/auth_controller_api_test.exs index 47aa1634e..ca6f41561 100644 --- a/apps/gamend_web/test/gamend_web/controllers/auth_controller_api_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/auth_controller_api_test.exs @@ -671,6 +671,46 @@ defmodule GamendWeb.AuthControllerApiTest do assert user.google_id == "gsub_1" end + test "refuses an account scheduled for deletion", %{conn: conn} do + defmodule MockGoogleTokeninfoScheduled do + def get(_url, _opts) do + {:ok, + %{ + status: 200, + body: %{ + "sub" => "gsub_scheduled", + "aud" => "webcid", + "iss" => "https://accounts.google.com", + "email" => "scheduled@example.com", + "expires_in" => "3600" + } + }} + end + end + + Application.put_env(:gamend_core, :google_tokeninfo_client, MockGoogleTokeninfoScheduled) + SettingsHelpers.put(:gamend_core, Gamend.OAuth.Providers, :google_web_client_id, "webcid") + SettingsHelpers.put(:gamend_core, Gamend.Accounts, :deletion_grace_days, 30) + + on_exit(fn -> + SettingsHelpers.delete(:gamend_core, Gamend.OAuth.Providers, :google_web_client_id) + SettingsHelpers.delete(:gamend_core, Gamend.Accounts, :deletion_grace_days) + end) + + {:ok, user} = + Gamend.Accounts.find_or_create_from_google(%{ + google_id: "gsub_scheduled", + email: "scheduled@example.com" + }) + + {:ok, {:scheduled, _, _}} = Gamend.Accounts.request_deletion(user) + + conn = post(conn, "/api/v1/auth/google/id_token", %{id_token: "any"}) + + assert %{"error" => "deletion_scheduled"} = json_response(conn, 403) + assert Gamend.Accounts.deletion_scheduled?(Gamend.Accounts.get_user!(user.id)) + end + test "returns 400 when aud does not match", %{conn: conn} do defmodule MockGoogleTokeninfoBadAud do def get(_url, _opts) do diff --git a/apps/gamend_web/test/gamend_web/controllers/auth_controller_test.exs b/apps/gamend_web/test/gamend_web/controllers/auth_controller_test.exs index 8eed02de3..1ce1373af 100644 --- a/apps/gamend_web/test/gamend_web/controllers/auth_controller_test.exs +++ b/apps/gamend_web/test/gamend_web/controllers/auth_controller_test.exs @@ -157,6 +157,61 @@ defmodule GamendWeb.AuthControllerTest do assert session.status == "completed" end + describe "an account scheduled for deletion" do + defmodule TestExchanger.ScheduledDiscord do + def exchange_discord_code(_code, _client_id, _secret, _redirect) do + {:ok, %{"id" => "d-scheduled", "email" => "leaving@example.com", "username" => "leaving"}} + end + end + + setup do + orig = Application.get_env(:gamend_web, :oauth_exchanger) + Application.put_env(:gamend_web, :oauth_exchanger, TestExchanger.ScheduledDiscord) + SettingsHelpers.put(:gamend_core, Accounts, :deletion_grace_days, 30) + + on_exit(fn -> + Application.put_env(:gamend_web, :oauth_exchanger, orig) + SettingsHelpers.delete(:gamend_core, Accounts, :deletion_grace_days) + end) + + {:ok, user} = + Accounts.find_or_create_from_discord(%{ + discord_id: "d-scheduled", + email: "leaving@example.com" + }) + + {:ok, {:scheduled, user, _}} = Accounts.request_deletion(user) + %{user: user} + end + + test "the polling flow answers deletion_scheduled and issues no tokens", + %{conn: conn, user: user} do + session_id = "sid-#{System.unique_integer([:positive])}" + OAuthSessions.create_session(session_id, %{provider: "discord", status: "pending"}) + + _conn = get(conn, "/auth/discord/callback?code=abc&state=#{session_id}") + + session = OAuthSessions.get_session(session_id) + assert session.status == "error" + assert session.data["error"] == "deletion_scheduled" + refute Map.has_key?(session.data, "access_token") + assert Accounts.deletion_scheduled?(Accounts.get_user!(user.id)) + end + + test "signing in with the provider on the website keeps the account", + %{conn: conn, user: user} do + # A browser flow's state, as the request step issues it. + state = "browser:#{System.unique_integer([:positive])}" + OAuthSessions.create_session(state, %{provider: "discord", status: "pending"}) + + conn = get(conn, "/auth/discord/callback?code=abc&state=#{state}") + + assert get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "will not be deleted" + refute Accounts.deletion_scheduled?(Accounts.get_user!(user.id)) + end + end + test "callback (google) success browser and api flows", %{conn: conn} do orig = Application.get_env(:gamend_web, :oauth_exchanger) diff --git a/apps/gamend_web/test/gamend_web/game_socket_test.exs b/apps/gamend_web/test/gamend_web/game_socket_test.exs new file mode 100644 index 000000000..b87ebdada --- /dev/null +++ b/apps/gamend_web/test/gamend_web/game_socket_test.exs @@ -0,0 +1,66 @@ +defmodule GamendWeb.GameSocketTest do + @moduledoc """ + `/socket/websocket` is served by the endpoint's own plug rather than by + `socket/3`, so its idle timeout and frame cap can be settings. These go + through the real endpoint to the upgrade: if Phoenix changes the internals + that plug calls, this is what fails. + """ + # Settings are global Application config. + use GamendWeb.ConnCase, async: false + + import Gamend.AccountsFixtures + + alias Gamend.SettingsHelpers + alias GamendWeb.Auth.Guardian + + # The upgrade check wants a `host` header, which `put_req_header/3` refuses + # to set (Plug keeps it in `conn.host`), so it goes in directly. + defp upgrade(conn, params) do + %{conn | req_headers: [{"host", "www.example.com"} | conn.req_headers]} + |> put_req_header("connection", "Upgrade") + |> put_req_header("upgrade", "websocket") + |> put_req_header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + |> put_req_header("sec-websocket-version", "13") + |> get("/socket/websocket", params) + end + + defp token do + {:ok, token, _claims} = Guardian.encode_and_sign(user_fixture()) + token + end + + test "upgrades with the default timeout and frame cap", %{conn: conn} do + conn = upgrade(conn, %{"token" => token(), "vsn" => "2.0.0"}) + + assert conn.halted + assert [{:websocket, {GamendWeb.UserSocket, _state, opts}}] = Plug.Test.sent_upgrades(conn) + assert opts[:timeout] == 300_000 + assert opts[:max_frame_size] == 131_072 + assert opts[:compress] == true + end + + test "the timeout and frame cap follow their settings", %{conn: conn} do + for {key, value} <- [socket_timeout_ms: 45_000, socket_max_frame_bytes: 4_096] do + SettingsHelpers.put(:gamend_web, GamendWeb.Realtime, key, value) + on_exit(fn -> SettingsHelpers.delete(:gamend_web, GamendWeb.Realtime, key) end) + end + + conn = upgrade(conn, %{"token" => token(), "vsn" => "2.0.0"}) + + assert [{:websocket, {GamendWeb.UserSocket, _state, opts}}] = Plug.Test.sent_upgrades(conn) + assert opts[:timeout] == 45_000 + assert opts[:max_frame_size] == 4_096 + end + + test "a bad token is refused before any upgrade", %{conn: conn} do + conn = upgrade(conn, %{"token" => "not-a-token", "vsn" => "2.0.0"}) + + assert conn.status == 403 + assert Plug.Test.sent_upgrades(conn) == [] + end + + test "other paths under /socket are not the game socket", %{conn: conn} do + conn = get(conn, "/socket/longpoll") + refute conn.status in [101, 403] + end +end diff --git a/priv/docs/30-clients/30-realtime.md b/priv/docs/30-clients/30-realtime.md index 72bd02306..902db99d5 100644 --- a/priv/docs/30-clients/30-realtime.md +++ b/priv/docs/30-clients/30-realtime.md @@ -230,6 +230,7 @@ The UserChannel also accepts a "call_hook" push from the client to invoke server - All broadcasts are fire-and-forget; subscribers don't acknowledge receipt - In a cluster, PubSub automatically distributes messages across nodes via pg2/Phoenix.PubSub.PG2 - WebSocket connections are authenticated on socket connect, not on channel join: the socket needs a valid JWT access token (a refresh token is refused), and a connection without one is rejected before any channel can be joined +- A socket that sends nothing for `GAMEND_REALTIME_SOCKET_TIMEOUT_MS` (default 5 minutes) is closed. The default is long because browsers pause a background tab's game loop, heartbeats included; a hard disconnect still ends the socket at once. A single frame larger than `GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES` (default 128 KB) is refused - Friend DMs are broadcast to both the sorted-pair topic and each user's personal topic, so the recipient receives the message even without subscribing to the friend chat topic directly. - Clients that cache messages locally can update in place: `chat_message_updated` carries the full message, and `chat_message_deleted` carries only its `id`. diff --git a/priv/docs/60-operations/40-settings.md b/priv/docs/60-operations/40-settings.md index 11839ea87..d154c047c 100644 --- a/priv/docs/60-operations/40-settings.md +++ b/priv/docs/60-operations/40-settings.md @@ -7,7 +7,7 @@ generated: by `mix gamend.settings.guide` - do not edit by hand; edit the # Settings Every setting the server has, with the environment variable that sets it. -309 settings across 28 groups. +311 settings across 28 groups. A setting is declared in the module that owns it, so this page and `.env.example` are generated from the same source the server reads. The @@ -425,6 +425,8 @@ Live values, and where each one came from, are on the | `GAMEND_REALTIME_PRESENCE_POOL_SIZE` | integer | `1` | Phoenix.Presence tracker shards. Must match on every node in a cluster; needs a full restart to change. | | `GAMEND_REALTIME_PUBSUB_POOL_SIZE` | integer | `1` | Phoenix.PubSub shards. Raise on nodes holding many thousands of sockets. | | `GAMEND_REALTIME_SOCKET_BUFFER_KB` | integer | `0` | Cap the per-connection socket read buffer, in KB. 0 leaves the OS default. Only lowers memory on platforms that honour it; does not change the TCP window. | +| `GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES` | integer | `131072` | Largest single WebSocket frame a game client may send, in bytes. | +| `GAMEND_REALTIME_SOCKET_TIMEOUT_MS` | integer | `300000` | How long a game socket may stay silent before it is closed, in ms. Longer keeps alt-tabbed players; shorter frees half-open sockets (and their seats) sooner. | ## Retention From 45dee3e8e509939d54ccf5e62afedc1221f98f5a Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 22:02:38 +0200 Subject: [PATCH 4/8] upd deps --- apps/gamend_core/mix.lock | 2 +- apps/gamend_web/mix.lock | 2 +- clients/package-lock.json | 8 ++++---- clients/package.json | 2 +- mix.lock | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/gamend_core/mix.lock b/apps/gamend_core/mix.lock index 11be67540..34ec49a7c 100644 --- a/apps/gamend_core/mix.lock +++ b/apps/gamend_core/mix.lock @@ -57,7 +57,7 @@ "oban": {:hex, :oban, "2.24.1", "2a609c54697ad2c44ba339df30491df2a40eda0758c95b5b879426e4e478bd1f", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef8482472cf198554400b7f8e36a0ffee75c3a64de425d5c7ee625d271925ac7"}, "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, "partitioned_buffer": {:hex, :partitioned_buffer, "0.4.3", "5afd9f9748e9dda78824f355689cfd781060df29e05acfcea8e809ebdf5fca9d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "79d33f27d859b00f2e4e3a01261f8af01a0e8b96ea04d1ae76c971f3a920cc31"}, - "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, diff --git a/apps/gamend_web/mix.lock b/apps/gamend_web/mix.lock index 63be6abf9..924922bd4 100644 --- a/apps/gamend_web/mix.lock +++ b/apps/gamend_web/mix.lock @@ -92,7 +92,7 @@ "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, "partitioned_buffer": {:hex, :partitioned_buffer, "0.4.3", "5afd9f9748e9dda78824f355689cfd781060df29e05acfcea8e809ebdf5fca9d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "79d33f27d859b00f2e4e3a01261f8af01a0e8b96ea04d1ae76c971f3a920cc31"}, "peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"}, - "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.9.1", "d4786b24bbe1eacc431049dbbb8d377f5359fce57b9d7cdd773bba4ac0af2ef6", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "de25e1a28cfb64413d8bdaf3ba5b64da1a19e0ed44a02320119523cc45da051d"}, diff --git a/clients/package-lock.json b/clients/package-lock.json index 0f118f80e..e93150fed 100644 --- a/clients/package-lock.json +++ b/clients/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "node-datachannel": "^0.33.4", "open": "^11.0.4", - "phoenix": "^1.8.14", + "phoenix": "^1.8.15", "protobufjs": "^8.8.0" } }, @@ -314,9 +314,9 @@ } }, "node_modules/phoenix": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.14.tgz", - "integrity": "sha512-erAGbssrbK7xe4l/tWXZTdsstCY+ufGTt/Kdin4uwceaP2TVqSL6v4s3flQOvIxShpwQx2/8NcOcBXOqgQ+ltw==", + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.15.tgz", + "integrity": "sha512-AU2t9smrLcSy7DF1ic3VKDVNmjnhcsOgdeXBiVrR29BgFLmO4EN+tyGx/lRY3naxl7eJtyLrnlBOfJu8gYvXdQ==", "license": "MIT" }, "node_modules/powershell-utils": { diff --git a/clients/package.json b/clients/package.json index 1f4e7aef5..7d89523f4 100644 --- a/clients/package.json +++ b/clients/package.json @@ -2,7 +2,7 @@ "dependencies": { "node-datachannel": "^0.33.4", "open": "^11.0.4", - "phoenix": "^1.8.14", + "phoenix": "^1.8.15", "protobufjs": "^8.8.0" }, "scripts": { diff --git a/mix.lock b/mix.lock index cf89494f1..de95a21db 100644 --- a/mix.lock +++ b/mix.lock @@ -91,7 +91,7 @@ "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, "partitioned_buffer": {:hex, :partitioned_buffer, "0.4.3", "5afd9f9748e9dda78824f355689cfd781060df29e05acfcea8e809ebdf5fca9d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "79d33f27d859b00f2e4e3a01261f8af01a0e8b96ea04d1ae76c971f3a920cc31"}, "peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"}, - "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.9.1", "d4786b24bbe1eacc431049dbbb8d377f5359fce57b9d7cdd773bba4ac0af2ef6", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "de25e1a28cfb64413d8bdaf3ba5b64da1a19e0ed44a02320119523cc45da051d"}, From 01e0f3a26fc2ad364460009c110f8974d48041df Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 23:04:29 +0200 Subject: [PATCH 5/8] Balaur editor dock: the engine's new ui, theme and plugin names ui::pill is ui::button, horizontal and vertical are row and column, colour tokens are _ (k.text_muted, k.success_text), roles follow the editor's roles.toml (chip with a checked flag, text_caption, note_danger), option keys are the widget's own (font_size, font_family, text_color), and register() declares panels with a title. --- balaur_addons/addons/gamend/editor/gamend.rn | 83 ++++++++++---------- clients/balaur_template/editor/gamend.rn | 83 ++++++++++---------- 2 files changed, 82 insertions(+), 84 deletions(-) diff --git a/balaur_addons/addons/gamend/editor/gamend.rn b/balaur_addons/addons/gamend/editor/gamend.rn index 8ff162e84..5b89e2123 100644 --- a/balaur_addons/addons/gamend/editor/gamend.rn +++ b/balaur_addons/addons/gamend/editor/gamend.rn @@ -26,7 +26,7 @@ pub fn state() { } pub fn register() { - #{ docks: [#{ id: "gamend", name: "Gamend", tall: true, scrolls: true, draw: draw }] } + #{ panels: [#{ id: "gamend", title: "Gamend", tall: true, scrolls: true, draw: draw }] } } fn core_tabs() { @@ -139,14 +139,14 @@ fn header(S, k, me) { let link = gamend::connection(); let url = link["url"]; let narrow = ui::available_width() < 560.0; - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); for name in ["production", "local"] { let at = target[name]; let on = target["name"] == name && url == at; - let role = if on { "chip_on" } else { "chip" }; + let role = "chip"; let label = if narrow { name } else { format!("{} · {}", name, host_of(at)) }; - if ui::pill(label, #{ role: role, tooltip: at }) && !on { + if ui::button(label, #{ role: role, checked: on, tooltip: at }) && !on { pick(S, me, name); } } @@ -156,7 +156,7 @@ fn header(S, k, me) { } }); if narrow { - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); links_and_status(k, link); }); @@ -166,10 +166,10 @@ fn header(S, k, me) { fn links_and_status(k, link) { let url = link["url"]; - if ui::pill("Open", #{ role: "chip", tooltip: url }) { + if ui::button("Open", #{ role: "chip", tooltip: url }) { engine::open_url(url); } - if ui::pill("Admin", #{ role: "chip", tooltip: url + "/admin" }) { + if ui::button("Admin", #{ role: "chip", tooltip: url + "/admin" }) { engine::open_url(url + "/admin"); } ui::add_space(8); @@ -198,8 +198,8 @@ fn status(k, link) { topics += socket["topics"].len(); } let sockets = if open > 0 { format!("socket open, {} topics", topics) } else { "no socket open" }; - let color = if user is String { k.sage } else { k.dim }; - ui::label(format!("{} · {}", who, sockets), #{ size: 11.5, font: ui::FONT_MONO, color: color, truncate: true }); + let color = if user is String { k.success_text } else { k.text_muted }; + ui::label(format!("{} · {}", who, sockets), #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, truncate: true }); } fn overview(S, k, me) { @@ -242,12 +242,12 @@ fn user(S, k, me) { field(S, k, "access token", session["access_token"], #{ mask: true, copy: true }); field(S, k, "expires", expires(session["expires_at"]), #{}); field(S, k, "refresh token", session["refresh_token"], #{ mask: true, copy: true }); - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Keep for the next run", #{ role: "chip" }) { + if ui::button("Keep for the next run", #{ role: "chip" }) { keep_session(session); } - if ui::pill("Sign out here", #{ role: "chip" }) { + if ui::button("Sign out here", #{ role: "chip" }) { gamend::restore(()); forget(me, ""); } @@ -259,9 +259,9 @@ fn user(S, k, me) { fn signed_out(S, k, me) { let empty = S.kit.empty; empty(k, "not signed in"); - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Sign in with this device", #{ role: "chip" }) { + if ui::button("Sign in with this device", #{ role: "chip" }) { forget(me, "login"); let (ok, id) = script::attempt(|| gamend::login((), #{ device_id: engine::device_id() })); if ok { @@ -276,7 +276,7 @@ fn signed_out(S, k, me) { let asked = me.requests.get("login").unwrap_or(()); let reply = if login is Tuple && asked is i64 { gamend::reply(asked) } else { login }; if !(reply is Tuple) { - line(brief(reply), k.dim); + line(brief(reply), k.text_muted); } let saved = saved_session(); if saved is Object { @@ -289,13 +289,13 @@ fn signed_out(S, k, me) { let secret = key.ends_with("token"); field(S, k, key, saved[key], #{ mask: secret, copy: secret }); } - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Restore it", #{ role: "chip" }) { + if ui::button("Restore it", #{ role: "chip" }) { gamend::restore(saved); forget(me, ""); } - if ui::pill("Forget it", #{ role: "chip" }) { + if ui::button("Forget it", #{ role: "chip" }) { save::remove(SESSION_SLOT); } }); @@ -333,11 +333,11 @@ fn data(S, k, me) { files(S, k, "gamend-data", #{ only: [SESSION_SLOT + ".toml", PREFS_SLOT + ".toml"] }); section(k, "On the server, key-value"); let asked = #{ get: false }; - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - let (typed, _, entered) = ui::text_field("gamend:kv", "a key", #{ role: "field", value: me.kv_key, width: 220.0, font: ui::FONT_MONO }); + let (typed, _, entered) = ui::text_field("gamend:kv", "a key", #{ role: "input", value: me.kv_key, width: 220.0, font_family: ui::FONT_MONO }); me.kv_key = typed; - if ui::pill("Get", #{ role: "chip" }) || entered { + if ui::button("Get", #{ role: "chip" }) || entered { asked.get = true; } }); @@ -351,7 +351,7 @@ fn data(S, k, me) { section(k, "Rows the game keeps"); let rows = game_rows(me); if rows.len() == 0 { - line("none: the rows a playing game subscribed to with `register_kv` show here", k.faint); + line("none: the rows a playing game subscribed to with `register_kv` show here", k.text_subtle); } else { let tree = S.kit.tree; tree(S, k, "gamend:rows", rows, #{}); @@ -427,16 +427,15 @@ fn refresh_on(me, newest, topic, key) { } fn activity(S, k, me) { - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); for name in ["all", "calls", "messages", "errors"] { - let role = if me.filter == name { "chip_on" } else { "chip" }; - if ui::pill(name, #{ role: role }) { + if ui::button(name, #{ role: "chip", checked: me.filter == name }) { me.filter = name; } } ui::add_space(12); - if ui::pill("Clear", #{ role: "chip" }) { + if ui::button("Clear", #{ role: "chip" }) { gamend::clear_activity(); } }); @@ -483,13 +482,13 @@ fn activity_row(S, k, me, row, n) { let ms = row["ms"]; let took = if ms is f64 { format!("{} ms", ms as i64) } else { "" }; let mark = if open { "▾" } else { "▸" }; - let color = if failed(row) { k.accent } else if row["kind"] == "message" { k.faint } else { k.dim }; - ui::horizontal(#{ height: 24 }, || { + let color = if failed(row) { k.primary_text } else if row["kind"] == "message" { k.text_subtle } else { k.text_muted }; + ui::row(#{ height: 24 }, || { ui::spacing(4, 0); - if ui::pill(format!("{} {}", mark, row["kind"]), #{ role: "chip" }) { + if ui::button(format!("{} {}", mark, row["kind"]), #{ role: "chip" }) { me.open[key] = !open; } - ui::label(format!("{} {} {}", row["what"], row["status"], took), #{ size: 11.5, font: ui::FONT_MONO, color: color, truncate: true }); + ui::label(format!("{} {} {}", row["what"], row["status"], took), #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, truncate: true }); }); if open { let tree = S.kit.tree; @@ -514,12 +513,12 @@ fn logs(S, k) { for name in ["shipping", "floor", "run", "pending", "last flush", "accepted", "dropped"] { field(S, k, name, report.get(name).unwrap_or(""), #{}); } - if ui::pill("Flush now", #{ role: "chip" }) { + if ui::button("Flush now", #{ role: "chip" }) { let root = scene::root(); root.meta[FLUSH_KEY] = "yes"; } } else { - line("not shipping: put addons/gamend/log_sink.rn on a node that lives as long as the game", k.faint); + line("not shipping: put addons/gamend/log_sink.rn on a node that lives as long as the game", k.text_subtle); } } @@ -543,13 +542,13 @@ fn feature_tab(S, k, me, name) { } } if !(gamend::session() is Object) { - line("not signed in: most of these are the player's own, so sign in on the User tab", k.faint); + line("not signed in: most of these are the player's own, so sign in on the User tab", k.text_subtle); } - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); if feature.id != "" { let held = me.ids.get(name).unwrap_or(""); - let (typed, _, _) = ui::text_field("gamend:id:" + name, feature.id, #{ role: "field", value: held, width: 220.0, font: ui::FONT_MONO }); + let (typed, _, _) = ui::text_field("gamend:id:" + name, feature.id, #{ role: "input", value: held, width: 220.0, font_family: ui::FONT_MONO }); me.ids[name] = typed; } refresh_button(me, name + "/"); @@ -560,7 +559,7 @@ fn feature_tab(S, k, me, name) { let path = view[1]; section(k, format!("{} · GET {}", label, path)); if path.contains("{id}") && id == "" { - line(format!("needs a {}", feature.id), k.faint); + line(format!("needs a {}", feature.id), k.text_subtle); continue; } let key = format!("{}/{}/{}", name, label, id); @@ -569,7 +568,7 @@ fn feature_tab(S, k, me, name) { } fn refresh_button(me, prefix) { - if ui::pill("Refresh", #{ role: "chip" }) { + if ui::button("Refresh", #{ role: "chip" }) { forget(me, prefix); } } @@ -621,22 +620,22 @@ fn forget(me, prefix) { /// A reply drawn: a structure when it came back, a line when it did not. fn show(S, k, key, reply) { if reply is Tuple { - line("loading…", k.faint); + line("loading…", k.text_subtle); return; } if reply["kind"] == "error" { - line(reply["error"], k.accent); + line(reply["error"], k.primary_text); return; } let status = reply.get("status").unwrap_or(0); let body = reply.get("body").unwrap_or(()); if status >= 400 { - line(format!("{}: {}", status, short(body)), k.accent); + line(format!("{}: {}", status, short(body)), k.primary_text); return; } let shown = if body is Object && body.contains_key("data") { body["data"] } else { body }; if shown is Tuple { - line(format!("{}: empty", status), k.faint); + line(format!("{}: empty", status), k.text_subtle); return; } let tree = S.kit.tree; @@ -734,5 +733,5 @@ fn listed(items) { } fn line(text, color) { - ui::label(text, #{ size: 11.5, font: ui::FONT_MONO, color: color, wrap: true }); + ui::label(text, #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, wrap: true }); } diff --git a/clients/balaur_template/editor/gamend.rn b/clients/balaur_template/editor/gamend.rn index 8ff162e84..5b89e2123 100644 --- a/clients/balaur_template/editor/gamend.rn +++ b/clients/balaur_template/editor/gamend.rn @@ -26,7 +26,7 @@ pub fn state() { } pub fn register() { - #{ docks: [#{ id: "gamend", name: "Gamend", tall: true, scrolls: true, draw: draw }] } + #{ panels: [#{ id: "gamend", title: "Gamend", tall: true, scrolls: true, draw: draw }] } } fn core_tabs() { @@ -139,14 +139,14 @@ fn header(S, k, me) { let link = gamend::connection(); let url = link["url"]; let narrow = ui::available_width() < 560.0; - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); for name in ["production", "local"] { let at = target[name]; let on = target["name"] == name && url == at; - let role = if on { "chip_on" } else { "chip" }; + let role = "chip"; let label = if narrow { name } else { format!("{} · {}", name, host_of(at)) }; - if ui::pill(label, #{ role: role, tooltip: at }) && !on { + if ui::button(label, #{ role: role, checked: on, tooltip: at }) && !on { pick(S, me, name); } } @@ -156,7 +156,7 @@ fn header(S, k, me) { } }); if narrow { - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); links_and_status(k, link); }); @@ -166,10 +166,10 @@ fn header(S, k, me) { fn links_and_status(k, link) { let url = link["url"]; - if ui::pill("Open", #{ role: "chip", tooltip: url }) { + if ui::button("Open", #{ role: "chip", tooltip: url }) { engine::open_url(url); } - if ui::pill("Admin", #{ role: "chip", tooltip: url + "/admin" }) { + if ui::button("Admin", #{ role: "chip", tooltip: url + "/admin" }) { engine::open_url(url + "/admin"); } ui::add_space(8); @@ -198,8 +198,8 @@ fn status(k, link) { topics += socket["topics"].len(); } let sockets = if open > 0 { format!("socket open, {} topics", topics) } else { "no socket open" }; - let color = if user is String { k.sage } else { k.dim }; - ui::label(format!("{} · {}", who, sockets), #{ size: 11.5, font: ui::FONT_MONO, color: color, truncate: true }); + let color = if user is String { k.success_text } else { k.text_muted }; + ui::label(format!("{} · {}", who, sockets), #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, truncate: true }); } fn overview(S, k, me) { @@ -242,12 +242,12 @@ fn user(S, k, me) { field(S, k, "access token", session["access_token"], #{ mask: true, copy: true }); field(S, k, "expires", expires(session["expires_at"]), #{}); field(S, k, "refresh token", session["refresh_token"], #{ mask: true, copy: true }); - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Keep for the next run", #{ role: "chip" }) { + if ui::button("Keep for the next run", #{ role: "chip" }) { keep_session(session); } - if ui::pill("Sign out here", #{ role: "chip" }) { + if ui::button("Sign out here", #{ role: "chip" }) { gamend::restore(()); forget(me, ""); } @@ -259,9 +259,9 @@ fn user(S, k, me) { fn signed_out(S, k, me) { let empty = S.kit.empty; empty(k, "not signed in"); - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Sign in with this device", #{ role: "chip" }) { + if ui::button("Sign in with this device", #{ role: "chip" }) { forget(me, "login"); let (ok, id) = script::attempt(|| gamend::login((), #{ device_id: engine::device_id() })); if ok { @@ -276,7 +276,7 @@ fn signed_out(S, k, me) { let asked = me.requests.get("login").unwrap_or(()); let reply = if login is Tuple && asked is i64 { gamend::reply(asked) } else { login }; if !(reply is Tuple) { - line(brief(reply), k.dim); + line(brief(reply), k.text_muted); } let saved = saved_session(); if saved is Object { @@ -289,13 +289,13 @@ fn signed_out(S, k, me) { let secret = key.ends_with("token"); field(S, k, key, saved[key], #{ mask: secret, copy: secret }); } - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - if ui::pill("Restore it", #{ role: "chip" }) { + if ui::button("Restore it", #{ role: "chip" }) { gamend::restore(saved); forget(me, ""); } - if ui::pill("Forget it", #{ role: "chip" }) { + if ui::button("Forget it", #{ role: "chip" }) { save::remove(SESSION_SLOT); } }); @@ -333,11 +333,11 @@ fn data(S, k, me) { files(S, k, "gamend-data", #{ only: [SESSION_SLOT + ".toml", PREFS_SLOT + ".toml"] }); section(k, "On the server, key-value"); let asked = #{ get: false }; - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); - let (typed, _, entered) = ui::text_field("gamend:kv", "a key", #{ role: "field", value: me.kv_key, width: 220.0, font: ui::FONT_MONO }); + let (typed, _, entered) = ui::text_field("gamend:kv", "a key", #{ role: "input", value: me.kv_key, width: 220.0, font_family: ui::FONT_MONO }); me.kv_key = typed; - if ui::pill("Get", #{ role: "chip" }) || entered { + if ui::button("Get", #{ role: "chip" }) || entered { asked.get = true; } }); @@ -351,7 +351,7 @@ fn data(S, k, me) { section(k, "Rows the game keeps"); let rows = game_rows(me); if rows.len() == 0 { - line("none: the rows a playing game subscribed to with `register_kv` show here", k.faint); + line("none: the rows a playing game subscribed to with `register_kv` show here", k.text_subtle); } else { let tree = S.kit.tree; tree(S, k, "gamend:rows", rows, #{}); @@ -427,16 +427,15 @@ fn refresh_on(me, newest, topic, key) { } fn activity(S, k, me) { - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); for name in ["all", "calls", "messages", "errors"] { - let role = if me.filter == name { "chip_on" } else { "chip" }; - if ui::pill(name, #{ role: role }) { + if ui::button(name, #{ role: "chip", checked: me.filter == name }) { me.filter = name; } } ui::add_space(12); - if ui::pill("Clear", #{ role: "chip" }) { + if ui::button("Clear", #{ role: "chip" }) { gamend::clear_activity(); } }); @@ -483,13 +482,13 @@ fn activity_row(S, k, me, row, n) { let ms = row["ms"]; let took = if ms is f64 { format!("{} ms", ms as i64) } else { "" }; let mark = if open { "▾" } else { "▸" }; - let color = if failed(row) { k.accent } else if row["kind"] == "message" { k.faint } else { k.dim }; - ui::horizontal(#{ height: 24 }, || { + let color = if failed(row) { k.primary_text } else if row["kind"] == "message" { k.text_subtle } else { k.text_muted }; + ui::row(#{ height: 24 }, || { ui::spacing(4, 0); - if ui::pill(format!("{} {}", mark, row["kind"]), #{ role: "chip" }) { + if ui::button(format!("{} {}", mark, row["kind"]), #{ role: "chip" }) { me.open[key] = !open; } - ui::label(format!("{} {} {}", row["what"], row["status"], took), #{ size: 11.5, font: ui::FONT_MONO, color: color, truncate: true }); + ui::label(format!("{} {} {}", row["what"], row["status"], took), #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, truncate: true }); }); if open { let tree = S.kit.tree; @@ -514,12 +513,12 @@ fn logs(S, k) { for name in ["shipping", "floor", "run", "pending", "last flush", "accepted", "dropped"] { field(S, k, name, report.get(name).unwrap_or(""), #{}); } - if ui::pill("Flush now", #{ role: "chip" }) { + if ui::button("Flush now", #{ role: "chip" }) { let root = scene::root(); root.meta[FLUSH_KEY] = "yes"; } } else { - line("not shipping: put addons/gamend/log_sink.rn on a node that lives as long as the game", k.faint); + line("not shipping: put addons/gamend/log_sink.rn on a node that lives as long as the game", k.text_subtle); } } @@ -543,13 +542,13 @@ fn feature_tab(S, k, me, name) { } } if !(gamend::session() is Object) { - line("not signed in: most of these are the player's own, so sign in on the User tab", k.faint); + line("not signed in: most of these are the player's own, so sign in on the User tab", k.text_subtle); } - ui::horizontal(#{ height: 26 }, || { + ui::row(#{ height: 26 }, || { ui::spacing(4, 0); if feature.id != "" { let held = me.ids.get(name).unwrap_or(""); - let (typed, _, _) = ui::text_field("gamend:id:" + name, feature.id, #{ role: "field", value: held, width: 220.0, font: ui::FONT_MONO }); + let (typed, _, _) = ui::text_field("gamend:id:" + name, feature.id, #{ role: "input", value: held, width: 220.0, font_family: ui::FONT_MONO }); me.ids[name] = typed; } refresh_button(me, name + "/"); @@ -560,7 +559,7 @@ fn feature_tab(S, k, me, name) { let path = view[1]; section(k, format!("{} · GET {}", label, path)); if path.contains("{id}") && id == "" { - line(format!("needs a {}", feature.id), k.faint); + line(format!("needs a {}", feature.id), k.text_subtle); continue; } let key = format!("{}/{}/{}", name, label, id); @@ -569,7 +568,7 @@ fn feature_tab(S, k, me, name) { } fn refresh_button(me, prefix) { - if ui::pill("Refresh", #{ role: "chip" }) { + if ui::button("Refresh", #{ role: "chip" }) { forget(me, prefix); } } @@ -621,22 +620,22 @@ fn forget(me, prefix) { /// A reply drawn: a structure when it came back, a line when it did not. fn show(S, k, key, reply) { if reply is Tuple { - line("loading…", k.faint); + line("loading…", k.text_subtle); return; } if reply["kind"] == "error" { - line(reply["error"], k.accent); + line(reply["error"], k.primary_text); return; } let status = reply.get("status").unwrap_or(0); let body = reply.get("body").unwrap_or(()); if status >= 400 { - line(format!("{}: {}", status, short(body)), k.accent); + line(format!("{}: {}", status, short(body)), k.primary_text); return; } let shown = if body is Object && body.contains_key("data") { body["data"] } else { body }; if shown is Tuple { - line(format!("{}: empty", status), k.faint); + line(format!("{}: empty", status), k.text_subtle); return; } let tree = S.kit.tree; @@ -734,5 +733,5 @@ fn listed(items) { } fn line(text, color) { - ui::label(text, #{ size: 11.5, font: ui::FONT_MONO, color: color, wrap: true }); + ui::label(text, #{ font_size: 11.5, font_family: ui::FONT_MONO, text_color: color, wrap: true }); } From 2fac8b394d51dfad17f1728edcd9fdeddfd929e5 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 25 Sep 2026 23:37:38 +0200 Subject: [PATCH 6/8] Retention and IP-ban sync take enabled: false; boot log reads token TTLs Found fixing forge and gamend_starter against this branch: - Gamend.Retention and GamendWeb.IpBanSync take enabled: false, like the other background workers, and gamend's test configs set both. The retention sweep's first full run, five minutes after boot, landed inside long suites outside the SQL sandbox; the IP-ban boot load held a pooled connection a host suite in sandbox auto mode then lacked. - The boot log's JWT line read the Guardian ttl key, which is gone, and always said "TTL: default". It reads GamendWeb.Auth.Tokens.ttls/0. - The stable-storage-URL migration's queries no longer log at debug. - CHANGELOG: the full list of workers a host's test config turns off, and that a host handing out non-avatar/icon keys from a private S3 bucket must ask for Storage.url(key, signed: true). --- CHANGELOG.md | 4 +++- apps/gamend_core/config/test.exs | 7 ++++--- apps/gamend_core/lib/gamend/retention.ex | 11 +++++++++-- .../migrations/20260925130200_stable_storage_urls.exs | 6 ++++-- apps/gamend_web/config/test.exs | 4 ++++ apps/gamend_web/lib/gamend_web/ip_ban_sync.ex | 11 +++++++++-- config/test.exs | 11 ++++++++--- lib/gamend_host/application.ex | 11 ++++++----- 8 files changed, 47 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc0b4148..256192350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - [fixed] **Abandoned lobbies and seats are released within a minute of their window.** `GAMEND_RETENTION_ABANDONED_LOBBY_MINUTES` and `_PARTY_MINUTES` say 15 minutes, but the sweep that applies them ran every six hours, so a disconnected player could sit on `already_in_lobby` for up to 6h15m. The classes that free live state (offline lobby and party seats, abandoned parties, abandoned lobbies) now also run every `GAMEND_RETENTION_LIVE_INTERVAL_SECONDS` (default 60, `0` leaves them to the full sweep). The full sweep's cadence and batch size are settings too: `GAMEND_RETENTION_INTERVAL_HOURS` (6) and `GAMEND_RETENTION_BATCH_SIZE` (500). -- [fixed] **Avatars and icons on a private S3 bucket stopped loading after an hour.** With no `GAMEND_STORAGE_PUBLIC_URL`, `Storage.url/1` answered a link signed for an hour, and that is what uploads saved as `profile_url` and `icon_url`. It now answers `/storage/`, which redirects to a freshly signed link (`GAMEND_STORAGE_SIGNED_URL_SECONDS`, default 3600, cached for half of it). `Storage.url(key, signed: true)` gives the signed link itself, for display only. A migration rewrites the signed links already stored in `users`, `groups`, `quests`, `leaderboards` and `tournaments`. +- [fixed] **Avatars and icons on a private S3 bucket stopped loading after an hour.** With no `GAMEND_STORAGE_PUBLIC_URL`, `Storage.url/1` answered a link signed for an hour, and that is what uploads saved as `profile_url` and `icon_url`. It now answers `/storage/`, which redirects to a freshly signed link (`GAMEND_STORAGE_SIGNED_URL_SECONDS`, default 3600, cached for half of it). `Storage.url(key, signed: true)` gives the signed link itself, for display only. A migration rewrites the signed links already stored in `users`, `groups`, `quests`, `leaderboards` and `tournaments`. `/storage/*key` serves only `avatars/` and `icons/`, so a host that hands out the URL of any other key from a private bucket must now ask for `Storage.url(key, signed: true)`, or the link is a 404. - [fixed] **An upload ticket said 600 seconds and accepted 900.** Both are now `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default 600), on local and S3 tickets alike. @@ -26,6 +26,8 @@ - Peer-to-peer signaling rate limits: `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT` / `_WINDOW_MS` (300 / 10s) and `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT` / `_WINDOW_MS` (150 / 30s). The admin rate-limiting page reads them. - `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS` (2) and `_MAX_PLAYERS` (5), for a ticket that leaves its size out. +- [changed] **Background workers a host's test suite turns off.** Each runs outside the SQL sandbox, holds a pooled connection or sweeps on a timer, and a suite in sandbox auto mode can run out of connections to them. Gamend's own test configs set all of these, and a host's `config/test.exs` should too: `Gamend.Tournaments.Ticker`, `Gamend.Matchmaking.Worker`, `Gamend.Chat.Moderation.Sync` and `Gamend.Accounts.StalePresenceSweeper` under `:gamend_core`, plus two new switches, `config :gamend_core, Gamend.Retention, enabled: false` (both retention cycles; the full sweep's first run, five minutes after boot, landed inside long suites) and `config :gamend_web, GamendWeb.IpBanSync, enabled: false` (the IP-ban boot load). Each takes `enabled: false`. The boot log's JWT line now reads the token lifetimes from their settings; it read the Guardian `ttl` key, which is gone. + - [changed] **Config keys replaced by those settings.** The undeclared app-env keys `:hooks_call_timeout`, `:hooks_call_timeout_in_transaction` and `:slow_hook_threshold_ms` under `:gamend_core` are gone: set `call_timeout_ms`, `call_timeout_in_transaction_ms` and `slow_threshold_ms` on `Gamend.Hooks.PluginManager`. The signaling keys `:signaling_ws_window` and `:signaling_ice_window` are now `:signaling_ws_window_ms` and `:signaling_ice_window_ms`. `config/host_config.exs` no longer sets `ice_servers` for `:webrtc`; a host that sets it still overrides the WebRTC settings. - [added] **Token lifetimes are settings.** `GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES` (default 15) and `GAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS` (default 30) set how long API access and refresh tokens last. Both were literals: the access TTL in the Guardian config (in `HostRuntime` for prod, and again in `config/dev.exs` and `config/test.exs`), the refresh TTL at every call that signed one, and `expires_in: 900` in the login, refresh and OAuth answers. `GamendWeb.Auth.Guardian` now takes its `token_ttl` from `GamendWeb.Auth.Tokens.ttls/0`, so every token signed, anywhere, follows the settings, and `expires_in` is derived from the same value. A value below 1 counts as 1. The `ttl` key is gone from the Guardian config; a host that still sets one is ignored, because the per-type TTL wins over it. Tokens already issued keep the lifetime they were signed with. The Godot and C++ SDKs schedule their refresh from `expires_in` and need no change. diff --git a/apps/gamend_core/config/test.exs b/apps/gamend_core/config/test.exs index 3daf5accd..109bf9b43 100644 --- a/apps/gamend_core/config/test.exs +++ b/apps/gamend_core/config/test.exs @@ -83,9 +83,10 @@ config :gamend_core, Gamend.Accounts.PresenceWriter, flush_ms: 0 # ("database is locked"). Tests drive tick/0 and sweep/0 directly. config :gamend_core, Gamend.Tournaments.Ticker, enabled: false -# The live retention cycle would sweep outside any sandbox every minute; the -# full sweep's first run is five minutes out, past any test. -config :gamend_core, Gamend.Retention, live_interval_seconds: 0 +# Retention sweeps outside any sandbox: the live cycle every minute, the full +# one five minutes after boot, which a long suite reaches. Tests call +# `Gamend.Retention.prune_all/0` and `prune_live/0` themselves. +config :gamend_core, Gamend.Retention, enabled: false config :gamend_core, Gamend.Matchmaking.Worker, enabled: false # NOTE: deliberately NOT setting `async_inline: true` here, unlike the root diff --git a/apps/gamend_core/lib/gamend/retention.ex b/apps/gamend_core/lib/gamend/retention.ex index 2a0fbeb94..576eec319 100644 --- a/apps/gamend_core/lib/gamend/retention.ex +++ b/apps/gamend_core/lib/gamend/retention.ex @@ -141,10 +141,17 @@ defmodule Gamend.Retention do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end + # `enabled: false` in the app config keeps the sweeper supervised but idle, + # for test suites: both cycles would sweep outside the SQL sandbox, and a + # suite that runs past five minutes meets the first full sweep. `run_now/0` + # and `prune_all/0` still work. @impl true def init(_opts) do - Process.send_after(self(), :prune, @initial_delay_ms) - schedule_live() + if Keyword.get(Application.get_env(:gamend_core, __MODULE__, []), :enabled, true) do + Process.send_after(self(), :prune, @initial_delay_ms) + schedule_live() + end + {:ok, @never_run} end diff --git a/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs b/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs index a7125b6a9..8d513ec80 100644 --- a/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs +++ b/apps/gamend_core/priv/repo/migrations/20260925130200_stable_storage_urls.exs @@ -31,13 +31,15 @@ defmodule Gamend.Repo.Migrations.StableStorageUrls do from(r in table, where: like(field(r, ^column), "%X-Amz-Signature=%"), select: {type(r.id, :binary_id), field(r, ^column)} - ) + ), + log: false ) for {id, url} <- rows, key = storage_key(url, "#{prefix}/#{id}/") do repo().update_all( from(r in table, where: r.id == type(^id, :binary_id)), - set: [{column, "/storage/" <> key}] + [set: [{column, "/storage/" <> key}]], + log: false ) end end diff --git a/apps/gamend_web/config/test.exs b/apps/gamend_web/config/test.exs index 0232bee36..04fd5ef56 100644 --- a/apps/gamend_web/config/test.exs +++ b/apps/gamend_web/config/test.exs @@ -24,3 +24,7 @@ config :gamend_web, GamendWeb.Auth.Guardian, ttl: {15, :minutes} config :gamend_web, GamendWeb.Plugs.RateLimiter, enabled: false + +# The IP-ban boot load holds a database connection the tests need. Tests drive +# GamendWeb.Plugs.IpBan.load_persisted/0 directly. +config :gamend_web, GamendWeb.IpBanSync, enabled: false diff --git a/apps/gamend_web/lib/gamend_web/ip_ban_sync.ex b/apps/gamend_web/lib/gamend_web/ip_ban_sync.ex index cc2f13a90..c5d86398a 100644 --- a/apps/gamend_web/lib/gamend_web/ip_ban_sync.ex +++ b/apps/gamend_web/lib/gamend_web/ip_ban_sync.ex @@ -27,10 +27,17 @@ defmodule GamendWeb.IpBanSync do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end + # `enabled: false` in the app config keeps it supervised but idle, for test + # suites: the load at boot holds a database connection the tests need, and + # they call `IpBan.load_persisted/0` and `apply_remote/3` themselves. @impl true def init(_opts) do - Phoenix.PubSub.subscribe(Gamend.PubSub, IpBan.topic()) - {:ok, %{loaded?: false}, {:continue, :load_persisted}} + if Keyword.get(Application.get_env(:gamend_web, __MODULE__, []), :enabled, true) do + Phoenix.PubSub.subscribe(Gamend.PubSub, IpBan.topic()) + {:ok, %{loaded?: false}, {:continue, :load_persisted}} + else + {:ok, %{loaded?: true}} + end end @impl true diff --git a/config/test.exs b/config/test.exs index 2e2615c5e..121c813d0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -85,9 +85,10 @@ config :gamend_core, async_inline: true # ticker supervised but idle. Tests drive Gamend.Tournaments.tick/0 directly. config :gamend_core, Gamend.Tournaments.Ticker, enabled: false -# The live retention cycle would sweep outside any sandbox every minute; the -# full sweep's first run is five minutes out, past any test. -config :gamend_core, Gamend.Retention, live_interval_seconds: 0 +# Retention sweeps outside any sandbox: the live cycle every minute, the full +# one five minutes after boot, which a long suite reaches. Tests call +# `Gamend.Retention.prune_all/0` and `prune_live/0` themselves. +config :gamend_core, Gamend.Retention, enabled: false # Same for the matchmaking sweep: no sandbox connection, and on SQLite it # collides with the test's open write transaction ("database is locked"). @@ -98,6 +99,10 @@ config :gamend_core, Gamend.Matchmaking.Worker, enabled: false # Gamend.Chat.Moderation.Cache.load_persisted/0 directly. config :gamend_core, Gamend.Chat.Moderation.Sync, enabled: false +# And for the IP-ban boot load. Tests drive GamendWeb.Plugs.IpBan.load_persisted/0 +# directly. +config :gamend_web, GamendWeb.IpBanSync, enabled: false + # Disable app-level caching in tests to avoid stale reads across assertions. # Still provide the multilevel configuration so the cache can start. config :gamend_core, Gamend.Cache, diff --git a/lib/gamend_host/application.ex b/lib/gamend_host/application.ex index ba69e0637..d5de86cba 100644 --- a/lib/gamend_host/application.ex +++ b/lib/gamend_host/application.ex @@ -6,6 +6,7 @@ defmodule GamendHost.Application do alias Gamend.Hooks.PluginManager alias Gamend.OAuth.Providers alias Gamend.Repo.AdvisoryLock + alias GamendWeb.Auth.Tokens @impl true def start(_type, _args) do @@ -162,13 +163,13 @@ defmodule GamendHost.Application do end end + # The lifetimes are settings (`auth.*_token_ttl_*`), read through the same + # function Guardian signs with, so the line says what tokens really get. defp jwt_info do - guardian_config = - Application.get_env(:gamend_web, GamendWeb.Auth.Guardian, []) + %{"access" => {access, access_unit}, "refresh" => {refresh, refresh_unit}} = + Tokens.ttls() - ttl = guardian_config[:ttl] - ttl_str = if ttl, do: "#{elem(ttl, 0)} #{elem(ttl, 1)}", else: "default" - "JWT: Guardian (TTL: #{ttl_str})" + "JWT: Guardian (access TTL: #{access} #{access_unit}, refresh TTL: #{refresh} #{refresh_unit})" end defp oauth_info do From 261b2912e1aef6eba3ac4602134a8ae24c87a9e7 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 00:12:38 +0200 Subject: [PATCH 7/8] Public storage prefixes are a setting; lede and test-cache fixes Found fixing gamend_polyglot against this branch: - GAMEND_STORAGE_PUBLIC_PREFIXES (default avatars/,icons/) is what GET /storage/ serves. It was fixed in the controller, so a host with keys of its own (polyglot's cached PDFs) had no way to serve them on local disk, and on a private S3 bucket once Storage.url/1 became that route. - A blog post that opens with an image repeated its first paragraph: the lede was stripped only when it was the first

, and the image is. It is now the first paragraph with text, as extract_lede/1 already took it. - Outside prod, HostRuntime copied GAMEND_CACHE_ENABLED into bypass_mode both ways; the setting defaults to on, so every test config's bypass_mode: true became false and suites ran cached. Only turning the cache off is copied now. --- .env.example | 5 ++++ CHANGELOG.md | 6 ++++- apps/gamend_core/lib/gamend/content.ex | 20 ++++++++++++---- apps/gamend_core/lib/gamend/storage.ex | 24 +++++++++++++++++++ .../test/gamend/content/blog_test.exs | 21 ++++++++++++++++ .../controllers/api/v1/storage_controller.ex | 7 +++--- .../gamend_web/lib/gamend_web/host_runtime.ex | 12 ++++++---- .../test/gamend/runtime_config_test.exs | 22 +++++++++++++++++ .../gamend_web/account_protection_test.exs | 9 +++++++ priv/docs/45-storage/20-object-storage.md | 2 +- priv/docs/60-operations/40-settings.md | 3 ++- 11 files changed, 115 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 6c56ee24f..ea98346d3 100644 --- a/.env.example +++ b/.env.example @@ -1081,6 +1081,11 @@ # Custom endpoint, e.g. https://.r2.cloudflarestorage.com. # GAMEND_STORAGE_ENDPOINT= +# Key prefixes GET /storage/ serves to anyone. Add one only for keys +# that carry enough randomness to be unguessable; the rest need the admin +# API. +# GAMEND_STORAGE_PUBLIC_PREFIXES=avatars/,icons/ + # CDN or base URL serving stored objects, whichever backend is behind it. # GAMEND_STORAGE_PUBLIC_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 256192350..9ec66f7d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - [fixed] **Abandoned lobbies and seats are released within a minute of their window.** `GAMEND_RETENTION_ABANDONED_LOBBY_MINUTES` and `_PARTY_MINUTES` say 15 minutes, but the sweep that applies them ran every six hours, so a disconnected player could sit on `already_in_lobby` for up to 6h15m. The classes that free live state (offline lobby and party seats, abandoned parties, abandoned lobbies) now also run every `GAMEND_RETENTION_LIVE_INTERVAL_SECONDS` (default 60, `0` leaves them to the full sweep). The full sweep's cadence and batch size are settings too: `GAMEND_RETENTION_INTERVAL_HOURS` (6) and `GAMEND_RETENTION_BATCH_SIZE` (500). -- [fixed] **Avatars and icons on a private S3 bucket stopped loading after an hour.** With no `GAMEND_STORAGE_PUBLIC_URL`, `Storage.url/1` answered a link signed for an hour, and that is what uploads saved as `profile_url` and `icon_url`. It now answers `/storage/`, which redirects to a freshly signed link (`GAMEND_STORAGE_SIGNED_URL_SECONDS`, default 3600, cached for half of it). `Storage.url(key, signed: true)` gives the signed link itself, for display only. A migration rewrites the signed links already stored in `users`, `groups`, `quests`, `leaderboards` and `tournaments`. `/storage/*key` serves only `avatars/` and `icons/`, so a host that hands out the URL of any other key from a private bucket must now ask for `Storage.url(key, signed: true)`, or the link is a 404. +- [fixed] **Avatars and icons on a private S3 bucket stopped loading after an hour.** With no `GAMEND_STORAGE_PUBLIC_URL`, `Storage.url/1` answered a link signed for an hour, and that is what uploads saved as `profile_url` and `icon_url`. It now answers `/storage/`, which redirects to a freshly signed link (`GAMEND_STORAGE_SIGNED_URL_SECONDS`, default 3600, cached for half of it). `Storage.url(key, signed: true)` gives the signed link itself, for display only. A migration rewrites the signed links already stored in `users`, `groups`, `quests`, `leaderboards` and `tournaments`. `/storage/*key` serves only `avatars/` and `icons/`, so a host that hands out the URL of any other key from a private bucket must now ask for `Storage.url(key, signed: true)`, or the link is a 404. The prefixes are now a setting, `GAMEND_STORAGE_PUBLIC_PREFIXES` (default `avatars/,icons/`), so a host can serve keys of its own through the same route on local disk and S3 alike; add one only for keys with an avatar's randomness. - [fixed] **An upload ticket said 600 seconds and accepted 900.** Both are now `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default 600), on local and S3 tickets alike. @@ -26,6 +26,10 @@ - Peer-to-peer signaling rate limits: `GAMEND_RATELIMIT_SIGNALING_WS_LIMIT` / `_WINDOW_MS` (300 / 10s) and `GAMEND_RATELIMIT_SIGNALING_ICE_LIMIT` / `_WINDOW_MS` (150 / 30s). The admin rate-limiting page reads them. - `GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS` (2) and `_MAX_PLAYERS` (5), for a ticket that leaves its size out. +- [fixed] **A post that opens with an image repeated its first paragraph.** The show page drops the body's first paragraph when it is the lede, but only looked at the first `

`, which for such a post is the image. It now takes the first paragraph with text, as the lede itself does. + +- [fixed] **Test suites ran with the cache on.** Outside prod, `HostRuntime` copied `GAMEND_CACHE_ENABLED` into `bypass_mode` both ways, and the setting defaults to on, so every test config's `bypass_mode: true` was overwritten with `false`. Only turning the cache off is copied now; otherwise the compiled config decides. + - [changed] **Background workers a host's test suite turns off.** Each runs outside the SQL sandbox, holds a pooled connection or sweeps on a timer, and a suite in sandbox auto mode can run out of connections to them. Gamend's own test configs set all of these, and a host's `config/test.exs` should too: `Gamend.Tournaments.Ticker`, `Gamend.Matchmaking.Worker`, `Gamend.Chat.Moderation.Sync` and `Gamend.Accounts.StalePresenceSweeper` under `:gamend_core`, plus two new switches, `config :gamend_core, Gamend.Retention, enabled: false` (both retention cycles; the full sweep's first run, five minutes after boot, landed inside long suites) and `config :gamend_web, GamendWeb.IpBanSync, enabled: false` (the IP-ban boot load). Each takes `enabled: false`. The boot log's JWT line now reads the token lifetimes from their settings; it read the Guardian `ttl` key, which is gone. - [changed] **Config keys replaced by those settings.** The undeclared app-env keys `:hooks_call_timeout`, `:hooks_call_timeout_in_transaction` and `:slow_hook_threshold_ms` under `:gamend_core` are gone: set `call_timeout_ms`, `call_timeout_in_transaction_ms` and `slow_threshold_ms` on `Gamend.Hooks.PluginManager`. The signaling keys `:signaling_ws_window` and `:signaling_ice_window` are now `:signaling_ws_window_ms` and `:signaling_ice_window_ms`. `config/host_config.exs` no longer sets `ice_servers` for `:webrtc`; a host that sets it still overrides the WebRTC settings. diff --git a/apps/gamend_core/lib/gamend/content.ex b/apps/gamend_core/lib/gamend/content.ex index 3af446a56..66e3592fd 100644 --- a/apps/gamend_core/lib/gamend/content.ex +++ b/apps/gamend_core/lib/gamend/content.ex @@ -986,16 +986,26 @@ defmodule Gamend.Content do # first paragraph — so that paragraph is dropped here or every post opens by # repeating itself. Only an exact match is removed; an edited opening # paragraph stays. + # + # The first paragraph with any text, as `extract_lede/1` takes the first + # prose line: a post that opens with an image renders it as a `

` of its + # own, and checking only the first `

` left such a post repeating its lede. defp strip_lede_paragraph(html, lede) when is_binary(lede) and lede != "" do - case Regex.run(~r/\A\s*

(.*?)<\/p>\s*/s, html) do - [full, text] -> - if normalize_text(text) == normalize_text(lede) do - String.replace(html, full, "", global: false) + ~r/

(.*?)<\/p>\s*/s + |> Regex.scan(html, return: :index) + |> Enum.find(fn [_full, {start, length}] -> + normalize_text(binary_part(html, start, length)) != "" + end) + |> case do + [{start, length} = _full, {text_start, text_length}] -> + if normalize_text(binary_part(html, text_start, text_length)) == normalize_text(lede) do + binary_part(html, 0, start) <> + binary_part(html, start + length, byte_size(html) - start - length) else html end - _ -> + nil -> html end end diff --git a/apps/gamend_core/lib/gamend/storage.ex b/apps/gamend_core/lib/gamend/storage.ex index ea858a0a5..b88ec6182 100644 --- a/apps/gamend_core/lib/gamend/storage.ex +++ b/apps/gamend_core/lib/gamend/storage.ex @@ -92,6 +92,18 @@ defmodule Gamend.Storage do doc: "CDN or base URL serving stored objects, whichever backend is behind it." ) + # What the unauthenticated `GET /storage/` may serve. Avatar and icon + # keys carry 16 random bytes, so knowing one is being meant to have it. Admin + # uploads go at any path, and a hand-written key like `backups/db.sql` is + # guessable, so everything outside these is reached only through the admin + # API. A host adds a prefix for keys of its own that are just as random. + setting(:public_prefixes, :list, + default: ["avatars/", "icons/"], + doc: + "Key prefixes GET /storage/ serves to anyone. Add one only for keys that " <> + "carry enough randomness to be unguessable; the rest need the admin API." + ) + setting(:upload_ttl_seconds, :integer, default: 600, doc: @@ -160,6 +172,18 @@ defmodule Gamend.Storage do @spec url(Adapter.key(), keyword()) :: String.t() def url(key, opts \\ []), do: adapter().url(key, opts) + @doc """ + The key prefixes `GET /storage/` serves (`public_prefixes`), each + ending in `/` so `pdf` cannot also admit `pdfs-private/`. + """ + @spec public_prefixes() :: [String.t()] + def public_prefixes do + __MODULE__ + |> Gamend.Settings.get(:public_prefixes) + |> Enum.reject(&(&1 in [nil, "", "/"])) + |> Enum.map(&if(String.ends_with?(&1, "/"), do: &1, else: &1 <> "/")) + end + @doc "Seconds an upload ticket stays valid (`upload_ttl_seconds`)." @spec upload_ttl_seconds() :: pos_integer() def upload_ttl_seconds, do: max(Gamend.Settings.get(__MODULE__, :upload_ttl_seconds), 1) diff --git a/apps/gamend_core/test/gamend/content/blog_test.exs b/apps/gamend_core/test/gamend/content/blog_test.exs index 6617a74d6..3bc5f00ed 100644 --- a/apps/gamend_core/test/gamend/content/blog_test.exs +++ b/apps/gamend_core/test/gamend/content/blog_test.exs @@ -134,6 +134,27 @@ defmodule Gamend.Content.BlogTest do assert html =~ "

Second paragraph.

" end + test "a post that opens with an image still drops its lede from the body", %{root: root} do + File.write!(Path.join(root, "2026-09-02-pictured.md"), """ + # Pictured + + ![A new flag](/img/blog/flag.png) + + The opening paragraph, under a picture. + + Second paragraph. + """) + + Content.reload() + + assert Content.get_blog_post("pictured").lede == "The opening paragraph, under a picture." + + html = Content.blog_post_html("pictured") + assert html =~ "flag.png" + refute html =~ "The opening paragraph" + assert html =~ "Second paragraph." + end + test "the lede skips an import line left over from MDX" do assert Content.get_blog_post("hello").lede == "Opening paragraph that is not the description." end diff --git a/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex b/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex index 1226431b9..fad888a30 100644 --- a/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex +++ b/apps/gamend_web/lib/gamend_web/controllers/api/v1/storage_controller.ex @@ -90,7 +90,8 @@ defmodule GamendWeb.Api.V1.StorageController do |> redirect(external: Storage.url(key, signed: true)) end - # Prefixes this unauthenticated route may serve. + # Prefixes this unauthenticated route may serve: `Storage.public_prefixes/0`, + # `avatars/` and `icons/` unless a host adds its own. # # It used to serve *any* key in the store. Avatar and icon keys carry 16 bytes # of entropy so they are effectively unguessable, but the admin uploader @@ -99,10 +100,8 @@ defmodule GamendWeb.Api.V1.StorageController do # hand-written key like `backups/db.sql` is guessable by construction. # Everything outside these prefixes is reachable only through the # authenticated admin download route. - @public_prefixes ~w(avatars/ icons/) - defp publicly_servable?(key) do - Enum.any?(@public_prefixes, &String.starts_with?(key, &1)) + Enum.any?(Storage.public_prefixes(), &String.starts_with?(key, &1)) end defp serve_object(conn, key) do diff --git a/apps/gamend_web/lib/gamend_web/host_runtime.ex b/apps/gamend_web/lib/gamend_web/host_runtime.ex index f2005432e..a867a862d 100644 --- a/apps/gamend_web/lib/gamend_web/host_runtime.ex +++ b/apps/gamend_web/lib/gamend_web/host_runtime.ex @@ -261,13 +261,17 @@ defmodule GamendWeb.HostRuntime do # Outside prod the cache topology comes from the compiled config; honor the # GAMEND_CACHE_ENABLED toggle here so disabling it in dev/test isn't a - # silent no-op. + # silent no-op. Only turning it off is copied across: the setting defaults + # to on, and writing `bypass_mode: false` for that default overrode the + # `bypass_mode: true` every test config sets, so suites ran cached. defp cache_bypass_entries(:prod, _setting), do: [] defp cache_bypass_entries(_env, setting) do - [ - {:gamend_core, Gamend.Cache, [bypass_mode: not setting.(Gamend.Cache.Settings, :enabled)]} - ] + if setting.(Gamend.Cache.Settings, :enabled) do + [] + else + [{:gamend_core, Gamend.Cache, [bypass_mode: true]}] + end end defp prod_entries(env, setting, host, scheme, host_root) diff --git a/apps/gamend_web/test/gamend/runtime_config_test.exs b/apps/gamend_web/test/gamend/runtime_config_test.exs index 6f026bf64..d5ac1acce 100644 --- a/apps/gamend_web/test/gamend/runtime_config_test.exs +++ b/apps/gamend_web/test/gamend/runtime_config_test.exs @@ -134,6 +134,28 @@ defmodule Gamend.RuntimeConfigTest do end end + # Outside prod the compiled config owns the cache; the toggle may only turn + # it off. Copying the default "on" across overrode every test config's + # `bypass_mode: true`. + describe "cache outside prod" do + @tag env: %{"GAMEND_AUTH_SECRET_KEY_BASE" => String.duplicate("a", 64)} + test "leaves the compiled bypass alone while the cache is on" do + config = Config.Reader.read!(@runtime_config, env: :test) + + refute Keyword.has_key?(config[:gamend_core][Gamend.Cache] || [], :bypass_mode) + end + + @tag env: %{ + "GAMEND_AUTH_SECRET_KEY_BASE" => String.duplicate("a", 64), + "GAMEND_CACHE_ENABLED" => "false" + } + test "bypasses the cache when it is turned off" do + config = Config.Reader.read!(@runtime_config, env: :test) + + assert config[:gamend_core][Gamend.Cache][:bypass_mode] == true + end + end + describe "endpoint" do test "url, port and origins take their shapes from the settings", %{config: config} do endpoint = config[:gamend_web][GamendWeb.Endpoint] diff --git a/apps/gamend_web/test/gamend_web/account_protection_test.exs b/apps/gamend_web/test/gamend_web/account_protection_test.exs index bec825f35..c15770312 100644 --- a/apps/gamend_web/test/gamend_web/account_protection_test.exs +++ b/apps/gamend_web/test/gamend_web/account_protection_test.exs @@ -159,6 +159,15 @@ defmodule GamendWeb.AccountProtectionTest do test "keys outside the public prefixes are still not served", %{conn: conn} do assert json_response(get(conn, "/storage/backups/db.sql"), 404) end + + test "a host can add a prefix of its own", %{conn: conn} do + SettingsHelpers.put(:gamend_core, Gamend.Storage, :public_prefixes, ["avatars/", "pdf"]) + on_exit(fn -> SettingsHelpers.delete(:gamend_core, Gamend.Storage, :public_prefixes) end) + + assert redirected_to(get(conn, "/storage/pdf/abc123.pdf"), 302) =~ "pdf/abc123.pdf" + assert json_response(get(build_conn(), "/storage/pdfs-private/x.pdf"), 404) + assert json_response(get(build_conn(), "/storage/icons/x.png"), 404) + end end test "the request body limit is a setting" do diff --git a/priv/docs/45-storage/20-object-storage.md b/priv/docs/45-storage/20-object-storage.md index 44f8cedba..193aac7a9 100644 --- a/priv/docs/45-storage/20-object-storage.md +++ b/priv/docs/45-storage/20-object-storage.md @@ -53,7 +53,7 @@ Two limits bound the whole surface: `GAMEND_LIMITS_MAX_UPLOAD_BYTES` (5 MiB per ## Serving -On the local backend, objects are served by `GET /storage/*key` from the app itself. On S3 with `GAMEND_STORAGE_PUBLIC_URL` set, the object URL points there. On S3 without it the bucket is private: the stored URL is `/storage/`, which redirects to a link signed for `GAMEND_STORAGE_SIGNED_URL_SECONDS` (default `3600`), so a URL saved as a player's avatar keeps working after any one signed link expires. Upload tickets last `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default `600`) on both backends, and `expires_in` on the ticket says so. Cache policy is keyed by prefix: `avatars/` and `icons/` are immutable for a year, because every change mints a new key and makes their URL content-unique. Everything else revalidates via ETag. The serve route labels only real image bytes as images; any other stored type comes back as an opaque download, never rendered from the app's origin. +On the local backend, objects are served by `GET /storage/*key` from the app itself. On S3 with `GAMEND_STORAGE_PUBLIC_URL` set, the object URL points there. On S3 without it the bucket is private: the stored URL is `/storage/`, which redirects to a link signed for `GAMEND_STORAGE_SIGNED_URL_SECONDS` (default `3600`), so a URL saved as a player's avatar keeps working after any one signed link expires. Upload tickets last `GAMEND_STORAGE_UPLOAD_TTL_SECONDS` (default `600`) on both backends, and `expires_in` on the ticket says so. The route serves only the key prefixes in `GAMEND_STORAGE_PUBLIC_PREFIXES` (default `avatars/,icons/`); every other key is reached through the admin API. Add a prefix only for keys as random as an avatar's, never for hand-named ones. Cache policy is keyed by prefix: `avatars/` and `icons/` are immutable for a year, because every change mints a new key and makes their URL content-unique. Everything else revalidates via ETag. The serve route labels only real image bytes as images; any other stored type comes back as an opaque download, never rendered from the app's origin. ## Server scripting diff --git a/priv/docs/60-operations/40-settings.md b/priv/docs/60-operations/40-settings.md index d154c047c..7625595a5 100644 --- a/priv/docs/60-operations/40-settings.md +++ b/priv/docs/60-operations/40-settings.md @@ -7,7 +7,7 @@ generated: by `mix gamend.settings.guide` - do not edit by hand; edit the # Settings Every setting the server has, with the environment variable that sets it. -311 settings across 28 groups. +312 settings across 28 groups. A setting is declared in the module that owns it, so this page and `.env.example` are generated from the same source the server reads. The @@ -485,6 +485,7 @@ Live values, and where each one came from, are on the | `GAMEND_STORAGE_BUCKET` | string | - | **Required in production when `GAMEND_STORAGE_ADAPTER` is `s3`.** | | `GAMEND_STORAGE_DIR` | string | `"priv/storage"` | Directory the local adapter writes objects to. Point this at persistent storage (a mounted volume) in production — the default lives with the app and does not survive a redeploy. | | `GAMEND_STORAGE_ENDPOINT` | string | - | Custom endpoint, e.g. https://.r2.cloudflarestorage.com. | +| `GAMEND_STORAGE_PUBLIC_PREFIXES` | list | `avatars/,icons/` | Key prefixes GET /storage/ serves to anyone. Add one only for keys that carry enough randomness to be unguessable; the rest need the admin API. | | `GAMEND_STORAGE_PUBLIC_URL` | string | - | CDN or base URL serving stored objects, whichever backend is behind it. | | `GAMEND_STORAGE_REGION` | string | `"auto"` | Region, or "auto" for services that do not use one (R2, MinIO). | | `GAMEND_STORAGE_SECRET_ACCESS_KEY` | string | - | **Required in production when `GAMEND_STORAGE_ADAPTER` is `s3`.** Secret - never log or commit it. | From 11f41348222cae95705bc451413d12f716664c1e Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 08:58:19 +0200 Subject: [PATCH 8/8] update lock files --- apps/gamend_core/mix.lock | 6 +++--- apps/gamend_web/mix.lock | 10 +++++----- mix.lock | 10 +++++----- modules/plugins/webrtc_lobby_hook/mix.lock | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/gamend_core/mix.lock b/apps/gamend_core/mix.lock index 34ec49a7c..078aea8d9 100644 --- a/apps/gamend_core/mix.lock +++ b/apps/gamend_core/mix.lock @@ -29,14 +29,14 @@ "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, - "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, + "hackney": {:hex, :hackney, "4.8.1", "a42e5d2cac7a5671d7f971ae2fab1edd9af8543d1ec8098009ee5fc241efe95c", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "502271dac03ad7b06dd48ef11233f20a8170953ccb1598e42463c6184467d5e2"}, "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "httpoison": {:hex, :httpoison, "3.0.0", "8566a933bb9175236d1ec335978445b67cd1f5b5d3ead6ca4b80be469d41f5d9", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "9130197b7658901c493d6fcfb842fb9676300fa8a6c8ed058c8889cf1a77f3c2"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, + "lumis": {:hex, :lumis, "0.9.1", "5fb98531eb3e1946bff8f16478e87a14f4938f9702eb9d6fdacafe86d0c87adb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "659a739db127ae7ce16501f01fbbb19fb3bc0fcaddc89684a2cb71c2f5227930"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, @@ -67,7 +67,7 @@ "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, - "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, + "quic": {:hex, :quic, "2.0.1", "45ad1ffedd9a177277f78482bc28d07de93710d8a641f06345c1c27767c754ea", [:rebar3], [], "hexpm", "d907a5898467c3ded1d50dd12726577c342bb3bd6f8f40f5fe679805147d0b81"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, diff --git a/apps/gamend_web/mix.lock b/apps/gamend_web/mix.lock index 924922bd4..2436e23c5 100644 --- a/apps/gamend_web/mix.lock +++ b/apps/gamend_web/mix.lock @@ -11,7 +11,7 @@ "certifi": {:hex, :certifi, "2.17.0", "835748414307e15e05b17d0e518190228ce648b08d569a5cc93a85a40f3e5c9b", [:rebar3], [], "hexpm", "8122798a17f0293c80daada25d0f81c7f4d708c73fef782c7c9b1950e26e4d21"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, - "crc": {:hex, :crc, "0.10.6", "a52243715da06265399ade929b12e6807a82ddbd04231d8bd3069480aa890f01", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9e832833d48a5fff03cb7488f8aa5c08adda0a5fa8188bbe124cb17c4e39a00d"}, + "crc": {:hex, :crc, "0.11.0", "179cd2cb8d5f35d8b7d285eb12541da4680eefba253a5b97d4a949f7a30b56b4", [:mix, :rebar3], [], "hexpm", "b9e37d3e54c2d828611ad83dc2e0ea448c37cdb60a8b481355f9f68d69c9c3fc"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "crontab": {:hex, :crontab, "1.2.0", "503611820257939d5d0fd272eb2b454f48a470435a809479ddc2c40bb515495c", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ebd7ef4d831e1b20fa4700f0de0284a04cac4347e813337978e25b4cc5cc2207"}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, @@ -37,7 +37,7 @@ "ex_rtcp": {:hex, :ex_rtcp, "0.4.2", "ce723c86e73dc766540a19c9301be673fa0f9fea8843d8d84cdff24bb7111151", [:mix], [], "hexpm", "d1532d561ec6f8686ff73f96d269d015e5ed150e5816b358deddcdf61ddc1f8e"}, "ex_rtp": {:hex, :ex_rtp, "0.4.1", "878349aaa9c7e525ce2b9a277b5cee9fc247111c3954981111c391e0dcfdd4a0", [:mix], [], "hexpm", "3bc7d426adfa1f6b7c735bbf20fcf8688808d6531ab9616641ee156d11877703"}, "ex_sctp": {:hex, :ex_sctp, "0.1.3", "f989f2cc2b01010f3eaa7884d09c98429176ab255692f2364b3d03062764598e", [:mix], [{:rustler, "~> 0.37.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "3c7d69c0f91bb6e65c67cd9ab7098d386e67bf633f28e028a039578b72a45d9b"}, - "ex_sdp": {:hex, :ex_sdp, "1.2.0", "08e70ad04e079e8524f8e59c730c56be8d1bd46611cde35da52744c6e2573ef5", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "d104b0d4e6d4f74798df4bf0c0c4107fedd93126bed192854d86c776d610807f"}, + "ex_sdp": {:hex, :ex_sdp, "1.3.0", "ff8d276616cffb075fea4118c40461c5a0f9b1a27a6f45610111c2b116382882", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "cbf17d41476e45a60adfccdb44cb99dc9de174420a458e990953daedc4595b22"}, "ex_stun": {:hex, :ex_stun, "0.2.1", "f8273f16687c4a052583b91307783763675c3ee208d7746e9072eab1d7fe5318", [:mix], [], "hexpm", "df711db3610302d4161ce362321f98862fad858de48a75c135fd0b343f18e817"}, "ex_turn": {:hex, :ex_turn, "0.2.3", "7d10ce9c784c4a01dfb9adf42d94e52000db014b4557287067cd344b2939e0b6", [:mix], [{:ex_stun, "~> 0.2.1", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "f94874c6d240a162ba610deca0e3a42a6dbc2721c37310ec1ab6063ac1e677c0"}, "ex_webrtc": {:hex, :ex_webrtc, "0.17.0", "53586bed51d165cf96e1c496519fe768e95516618d244f22d4db7f03990cab08", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.18.1", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.16.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.2", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.1", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.3", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.1", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.2", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "a1efcc34e6903d1e9a37ae59312beab3ce258a6c30dccd5973a85ed8b545ee98"}, @@ -53,7 +53,7 @@ "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, - "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, + "hackney": {:hex, :hackney, "4.8.1", "a42e5d2cac7a5671d7f971ae2fab1edd9af8543d1ec8098009ee5fc241efe95c", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "502271dac03ad7b06dd48ef11233f20a8170953ccb1598e42463c6184467d5e2"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, @@ -64,7 +64,7 @@ "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"}, - "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, + "lumis": {:hex, :lumis, "0.9.1", "5fb98531eb3e1946bff8f16478e87a14f4938f9702eb9d6fdacafe86d0c87adb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "659a739db127ae7ce16501f01fbbb19fb3bc0fcaddc89684a2cb71c2f5227930"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, @@ -108,7 +108,7 @@ "prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, "qex": {:hex, :qex, "0.5.2", "a0c861a2de2380314c23ef592349824ca9016c5845380667ff1d9a22a8796f9b", [:mix], [], "hexpm", "6fb81bf3ae354a9abb471b9561538ea3e8540125d803b00f45cbccff52f00496"}, - "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, + "quic": {:hex, :quic, "2.0.1", "45ad1ffedd9a177277f78482bc28d07de93710d8a641f06345c1c27767c754ea", [:rebar3], [], "hexpm", "d907a5898467c3ded1d50dd12726577c342bb3bd6f8f40f5fe679805147d0b81"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, diff --git a/mix.lock b/mix.lock index de95a21db..b927afc05 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "certifi": {:hex, :certifi, "2.17.0", "835748414307e15e05b17d0e518190228ce648b08d569a5cc93a85a40f3e5c9b", [:rebar3], [], "hexpm", "8122798a17f0293c80daada25d0f81c7f4d708c73fef782c7c9b1950e26e4d21"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, - "crc": {:hex, :crc, "0.10.6", "a52243715da06265399ade929b12e6807a82ddbd04231d8bd3069480aa890f01", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9e832833d48a5fff03cb7488f8aa5c08adda0a5fa8188bbe124cb17c4e39a00d"}, + "crc": {:hex, :crc, "0.11.0", "179cd2cb8d5f35d8b7d285eb12541da4680eefba253a5b97d4a949f7a30b56b4", [:mix, :rebar3], [], "hexpm", "b9e37d3e54c2d828611ad83dc2e0ea448c37cdb60a8b481355f9f68d69c9c3fc"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "crontab": {:hex, :crontab, "1.2.0", "503611820257939d5d0fd272eb2b454f48a470435a809479ddc2c40bb515495c", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ebd7ef4d831e1b20fa4700f0de0284a04cac4347e813337978e25b4cc5cc2207"}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, @@ -37,7 +37,7 @@ "ex_rtcp": {:hex, :ex_rtcp, "0.4.2", "ce723c86e73dc766540a19c9301be673fa0f9fea8843d8d84cdff24bb7111151", [:mix], [], "hexpm", "d1532d561ec6f8686ff73f96d269d015e5ed150e5816b358deddcdf61ddc1f8e"}, "ex_rtp": {:hex, :ex_rtp, "0.4.1", "878349aaa9c7e525ce2b9a277b5cee9fc247111c3954981111c391e0dcfdd4a0", [:mix], [], "hexpm", "3bc7d426adfa1f6b7c735bbf20fcf8688808d6531ab9616641ee156d11877703"}, "ex_sctp": {:hex, :ex_sctp, "0.1.3", "f989f2cc2b01010f3eaa7884d09c98429176ab255692f2364b3d03062764598e", [:mix], [{:rustler, "~> 0.37.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "3c7d69c0f91bb6e65c67cd9ab7098d386e67bf633f28e028a039578b72a45d9b"}, - "ex_sdp": {:hex, :ex_sdp, "1.2.0", "08e70ad04e079e8524f8e59c730c56be8d1bd46611cde35da52744c6e2573ef5", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "d104b0d4e6d4f74798df4bf0c0c4107fedd93126bed192854d86c776d610807f"}, + "ex_sdp": {:hex, :ex_sdp, "1.3.0", "ff8d276616cffb075fea4118c40461c5a0f9b1a27a6f45610111c2b116382882", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "cbf17d41476e45a60adfccdb44cb99dc9de174420a458e990953daedc4595b22"}, "ex_stun": {:hex, :ex_stun, "0.2.1", "f8273f16687c4a052583b91307783763675c3ee208d7746e9072eab1d7fe5318", [:mix], [], "hexpm", "df711db3610302d4161ce362321f98862fad858de48a75c135fd0b343f18e817"}, "ex_turn": {:hex, :ex_turn, "0.2.3", "7d10ce9c784c4a01dfb9adf42d94e52000db014b4557287067cd344b2939e0b6", [:mix], [{:ex_stun, "~> 0.2.1", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "f94874c6d240a162ba610deca0e3a42a6dbc2721c37310ec1ab6063ac1e677c0"}, "ex_webrtc": {:hex, :ex_webrtc, "0.17.0", "53586bed51d165cf96e1c496519fe768e95516618d244f22d4db7f03990cab08", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.18.1", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.16.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.2", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.1", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.3", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.1", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.2", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "a1efcc34e6903d1e9a37ae59312beab3ce258a6c30dccd5973a85ed8b545ee98"}, @@ -52,7 +52,7 @@ "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, "guardian": {:hex, :guardian, "2.5.0", "dfe9533d734e1ca0341eaf1f7c951513b1c19006db69beaf2bae16cf63845ac2", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bc75cc9374825194060b0cd3230ce4440df23d8509d1f17472b210d3ee525790"}, "h2": {:hex, :h2, "0.12.3", "20e3fd0e384ec6f586e4736acd409a57ea87b4a56002d8dcf1132514b3d7600a", [:rebar3], [], "hexpm", "996af98698f7dc68bcc7688d70d97384b53ddd0286ba07e6d4a9ac54f1970d32"}, - "hackney": {:hex, :hackney, "4.8.0", "7f68e0d0acfd8fec20d0099a74df40a51c6f9ebf9d3e0c36297d7ce61189509a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "5be4d21d1e2b36f1ce428413a3e17ef73a45a27758e6530d6af35b0747cb4c13"}, + "hackney": {:hex, :hackney, "4.8.1", "a42e5d2cac7a5671d7f971ae2fab1edd9af8543d1ec8098009ee5fc241efe95c", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.3", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.7", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "502271dac03ad7b06dd48ef11233f20a8170953ccb1598e42463c6184467d5e2"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, @@ -62,7 +62,7 @@ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lumis": {:hex, :lumis, "0.9.0", "d24aca03369fc0103ee7e8d19bdb329190fbc3189944ae29f2bc194b4afa0227", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "552793f5937cc3ca2e16de68f3b3c16daa984ebd7d5f4849aeddee0b1ee397a1"}, + "lumis": {:hex, :lumis, "0.9.1", "5fb98531eb3e1946bff8f16478e87a14f4938f9702eb9d6fdacafe86d0c87adb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "659a739db127ae7ce16501f01fbbb19fb3bc0fcaddc89684a2cb71c2f5227930"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, @@ -107,7 +107,7 @@ "prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"}, "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, "qex": {:hex, :qex, "0.5.2", "a0c861a2de2380314c23ef592349824ca9016c5845380667ff1d9a22a8796f9b", [:mix], [], "hexpm", "6fb81bf3ae354a9abb471b9561538ea3e8540125d803b00f45cbccff52f00496"}, - "quic": {:hex, :quic, "2.0.0", "fe44a1cea79078879c4431fbe595f115d8f7932ed5fb45ae4e9c67a4dae5863c", [:rebar3], [], "hexpm", "69da19a76181e03f975afb18151c51f17f5623e3c38fd1a816d3148998edbbb5"}, + "quic": {:hex, :quic, "2.0.1", "45ad1ffedd9a177277f78482bc28d07de93710d8a641f06345c1c27767c754ea", [:rebar3], [], "hexpm", "d907a5898467c3ded1d50dd12726577c342bb3bd6f8f40f5fe679805147d0b81"}, "ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"}, "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, diff --git a/modules/plugins/webrtc_lobby_hook/mix.lock b/modules/plugins/webrtc_lobby_hook/mix.lock index b46f88b5c..bf21f97f6 100644 --- a/modules/plugins/webrtc_lobby_hook/mix.lock +++ b/modules/plugins/webrtc_lobby_hook/mix.lock @@ -1,7 +1,7 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},