mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8a743dc5 | ||
|
|
39f9beda5a | ||
|
|
f0b3c82cfd | ||
|
|
2808edf6d0 | ||
|
|
071263188a | ||
|
|
f9108f24ce | ||
|
|
e98b77a54a | ||
|
|
2636e9cc13 | ||
|
|
944b586f22 | ||
|
|
4f968bbc47 | ||
|
|
d433a039b5 | ||
|
|
b30ea361d3 | ||
|
|
9908518f5b | ||
|
|
364084c819 |
@@ -23,7 +23,6 @@ services:
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
@@ -202,11 +201,6 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/rate_limit/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-sms-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/sms/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-virus-scan-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/virus_scan/node_modules
|
||||
@@ -384,7 +378,6 @@ volumes:
|
||||
fluxer-api-mime-utils-node-modules:
|
||||
fluxer-api-nats-node-modules:
|
||||
fluxer-api-rate-limit-node-modules:
|
||||
fluxer-api-sms-node-modules:
|
||||
fluxer-api-virus-scan-node-modules:
|
||||
fluxer-api-worker-node-modules:
|
||||
fluxer-app-list-utils-node-modules:
|
||||
|
||||
Generated
+1
-1
@@ -1904,7 +1904,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -2067,6 +2066,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
Vendored
-9
@@ -34,7 +34,6 @@ FLUXER_KV_URL=redis://valkey:6379/0
|
||||
FLUXER_NATS_URL=nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
|
||||
FLUXER_INTERNAL_API_ENDPOINT=http://127.0.0.1:8080
|
||||
FLUXER_INTERNAL_GATEWAY_ENDPOINT=http://127.0.0.1:8771
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
|
||||
@@ -42,7 +41,6 @@ FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
|
||||
FLUXER_SVC_NATS_URL=nats://nats:4222
|
||||
FLUXER_SVC_SHARD_COUNT=1
|
||||
FLUXER_SVC_CACHE_TTL_MS=30000
|
||||
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
|
||||
|
||||
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
|
||||
@@ -61,7 +59,6 @@ FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
|
||||
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
|
||||
FLUXER_LIVEKIT_API_KEY=devkey
|
||||
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
|
||||
|
||||
FLUXER_API_PORT=8080
|
||||
@@ -107,9 +104,6 @@ FLUXER_EMAIL_SMTP_PORT=1025
|
||||
FLUXER_EMAIL_SMTP_USERNAME=dev
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=dev
|
||||
FLUXER_EMAIL_SMTP_SECURE=false
|
||||
FLUXER_SMS_ENABLED=false
|
||||
FLUXER_CAPTCHA_ENABLED=false
|
||||
FLUXER_CAPTCHA_PROVIDER=none
|
||||
FLUXER_SEARCH_ENGINE=meilisearch
|
||||
FLUXER_SEARCH_URL=http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
|
||||
@@ -131,6 +125,3 @@ PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
|
||||
AWS_EC2_METADATA_DISABLED=true
|
||||
AWS_ACCESS_KEY_ID=fluxer
|
||||
AWS_SECRET_ACCESS_KEY=fluxer-secret
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Every variable docker-compose.yml reads, uncommented when it has no default and
|
||||
# commented with its default when it has one. Compose expands top to bottom, so a
|
||||
# line using ${...} must sit below every name it reads.
|
||||
# Every variable docker-compose.yml reads. A value an install must set is
|
||||
# uncommented. A commented line shows the default, or an example where its comment
|
||||
# says so, and nothing after = means the service decides. An empty value keeps the
|
||||
# default too. Compose expands top to bottom, so a line using ${...} must sit below
|
||||
# every name it reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
@@ -68,7 +70,9 @@ FLUXER_IMAGE_TAG=v1
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# Set these to run Postgres or the object store outside the stack. Backing up a
|
||||
# store you moved out is yours to arrange, and an upgrade skips it.
|
||||
# store you moved out is yours to arrange. An upgrade dumps the bundled postgres
|
||||
# service and skips the dump only when the stack defines none. The values below
|
||||
# are examples.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
@@ -78,44 +82,102 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# Bucket names. The bundled store creates these. An outside store needs them to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
# With the object store outside the stack, add this overlay to COMPOSE_FILE and
|
||||
# the bundled seaweedfs no longer starts. Put it after any other overlay, such as
|
||||
# docker-compose.yml:docker-compose.proxy.yml:external-object-store.compose.yml.
|
||||
# Needs Compose 2.24.4 or newer.
|
||||
#COMPOSE_FILE=docker-compose.yml:external-object-store.compose.yml
|
||||
|
||||
# A full connection URL wins over the host, port and database above. The URL is an
|
||||
# example. The CA is the PEM text of the certificate, with \n for line breaks.
|
||||
#FLUXER_POSTGRES_URL=postgres://fluxer:[email protected]:5432/fluxer
|
||||
#FLUXER_POSTGRES_SSL_CA=
|
||||
# The Postgres table that holds the key-value store.
|
||||
#FLUXER_POSTGRES_KV_TABLE=fluxer_kv
|
||||
# media-proxy reads through these when the store serves reads from another
|
||||
# address or bucket.
|
||||
#FLUXER_S3_READ_ENDPOINT=
|
||||
#FLUXER_S3_READ_BUCKET=
|
||||
#FLUXER_S3_READ_BUCKET_STYLE=
|
||||
# A temporary S3 session token, read by media-proxy only.
|
||||
#FLUXER_S3_SESSION_TOKEN=
|
||||
# The bundled store refuses unsigned reads. Set false only for a public-read bucket.
|
||||
#FLUXER_S3_READ_SIGNED=true
|
||||
|
||||
# The other bundled services, pointed elsewhere. Removing a service from the
|
||||
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
|
||||
# The URLs below are examples.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
# How the stack talks to those services.
|
||||
#FLUXER_KV_MODE=standalone
|
||||
#FLUXER_SEARCH_ENGINE=meilisearch
|
||||
#FLUXER_SEARCH_USERNAME=
|
||||
#FLUXER_SEARCH_PASSWORD=
|
||||
#FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=true
|
||||
|
||||
# Voice off. The livekit service still runs until an override removes it.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless configured.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
#FLUXER_STRIPE_SECRET_KEY=
|
||||
#FLUXER_STRIPE_WEBHOOK_SECRET=
|
||||
# Stripe prices as one JSON object. The admin dashboard can set them instead.
|
||||
#FLUXER_STRIPE_PRICES={}
|
||||
#FLUXER_STRIPE_LEGACY_PRICES={}
|
||||
#FLUXER_API_DONATION_PROXY_KEY=
|
||||
#FLUXER_VISIONARIES_GUILD_ID=
|
||||
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
|
||||
|
||||
# Outside lookups, off unless turned on. The Tor exit list comes from
|
||||
# onionoo.torproject.org and the breached password check asks
|
||||
# NCMEC CyberTipline reporting, off by default. All four values are required
|
||||
# once it is on. The values below are examples.
|
||||
#FLUXER_NCMEC_ENABLED=true
|
||||
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
|
||||
#FLUXER_NCMEC_USERNAME=
|
||||
#FLUXER_NCMEC_PASSWORD=
|
||||
#[email protected]
|
||||
|
||||
# Upload virus scanning, off by default. No ClamAV container ships, so point
|
||||
# this at your own. The values below are examples.
|
||||
#FLUXER_CLAMAV_ENABLED=true
|
||||
#FLUXER_CLAMAV_HOST=clamav
|
||||
#FLUXER_CLAMAV_PORT=3310
|
||||
#FLUXER_CLAMAV_FAIL_OPEN=false
|
||||
|
||||
# Outside lookups, off unless turned on. The breached password check asks
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_TOR_EXIT_LIST_ENABLED=true
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
|
||||
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
|
||||
#FLUXER_IPINFO_API_KEY=
|
||||
# A local path, or an s3:// URL read with the S3 credentials of this file.
|
||||
#FLUXER_GEOIP_DB_PATH=
|
||||
|
||||
# The client address. Name the header your proxy actually writes, and turn the
|
||||
# trust off when nothing sits in front.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
# The client address. The edge sets X-Forwarded-For on every hop, so keep the
|
||||
# trust on and the default header. Turning the trust off makes the api refuse
|
||||
# every request outside its exempt routes with a 403.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal.
|
||||
#LOG_LEVEL=debug
|
||||
# How much the services write. LOG_LEVEL covers the api and worker and takes trace,
|
||||
# debug, info, warn, error or fatal. RUST_LOG covers the Rust services and takes
|
||||
# an EnvFilter such as debug. The gateway takes an Erlang level such as notice,
|
||||
# and LOGGER_LEVEL beats FLUXER_GATEWAY_LOGGER_LEVEL.
|
||||
#LOG_LEVEL=info
|
||||
#RUST_LOG=info
|
||||
#FLUXER_GATEWAY_LOGGER_LEVEL=info
|
||||
#LOGGER_LEVEL=
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
@@ -140,7 +202,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
# exist.
|
||||
#[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
|
||||
# The passkey RP ID defaults to FLUXER_DOMAIN, whatever FLUXER_PUBLIC_ORIGIN says.
|
||||
# Changing the RP ID invalidates every passkey registered against the old value.
|
||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||
@@ -151,6 +213,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
|
||||
# Provider requests the push container sends at once, 1 to 65536.
|
||||
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
|
||||
# The push container's provider addresses and relay hosts.
|
||||
#FLUXER_PUSH_SERVICE_APNS_BASE_URL=
|
||||
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
|
||||
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
|
||||
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
|
||||
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
|
||||
|
||||
# Direct mobile push through your own APNs and FCM credentials, off by default.
|
||||
#FLUXER_PUSH_APNS_ENABLED=false
|
||||
#FLUXER_PUSH_APNS_TEAM_ID=
|
||||
#FLUXER_PUSH_APNS_KEY_ID=
|
||||
#FLUXER_PUSH_APNS_PRIVATE_KEY=
|
||||
#FLUXER_PUSH_APNS_PRIVATE_KEY_PATH=
|
||||
#FLUXER_PUSH_APNS_APPS=
|
||||
#FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT=production
|
||||
#FLUXER_PUSH_FCM_ENABLED=false
|
||||
#FLUXER_PUSH_FCM_PROJECT_ID=
|
||||
#FLUXER_PUSH_FCM_CLIENT_EMAIL=
|
||||
#FLUXER_PUSH_FCM_PRIVATE_KEY=
|
||||
#FLUXER_PUSH_FCM_PRIVATE_KEY_PATH=
|
||||
#FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH=
|
||||
#FLUXER_PUSH_FCM_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
#FLUXER_PUSH_FCM_APPS=
|
||||
|
||||
|
||||
# Optional media policies, both off by default. See the operator docs.
|
||||
@@ -162,8 +247,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
# working. Needs a secret from openssl rand -base64 32, first entry signs and
|
||||
# every entry verifies.
|
||||
#
|
||||
# Each mode is off, report or enforce. Start at report. media-proxy reads these
|
||||
# at start, so apply with docker compose up -d media-proxy.
|
||||
# Each mode is off, report or enforce, and off is the default. Start at report.
|
||||
# media-proxy reads these at start, so apply with docker compose up -d
|
||||
# media-proxy. The values below are examples.
|
||||
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
|
||||
@@ -188,7 +274,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
# Let the SSO provider resolve to a private address. Off by default, so a
|
||||
# misconfigured provider URL cannot reach internal services. Turn it on only for
|
||||
# a provider on your own network.
|
||||
# a provider on your own network. The value below is an example.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
# These reach both LiveKit and the api. Change them together.
|
||||
@@ -205,13 +291,20 @@ LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# LiveKit finds its public address over STUN. A host that cannot reach one stops
|
||||
# with "could not resolve external IP", so set the address by hand instead, or
|
||||
# point STUN elsewhere.
|
||||
# point STUN elsewhere. The values below are examples.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
|
||||
|
||||
# The voice region users see, and how much LiveKit logs.
|
||||
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
|
||||
#FLUXER_LIVEKIT_LOG_LEVEL=info
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
#FLUXER_YOUTUBE_API_KEY=
|
||||
# Hosts the api never unfurls, comma separated.
|
||||
#FLUXER_API_UNFURL_IGNORED_HOSTS=
|
||||
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
@@ -223,14 +316,93 @@ FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=
|
||||
FLUXER_EMAIL_SMTP_SECURE=true
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_CAPTCHA_ENABLED=false
|
||||
FLUXER_CAPTCHA_PROVIDER=none
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
|
||||
|
||||
# Instance identity and account policy.
|
||||
#FLUXER_APP_PRODUCT_NAME=Fluxer
|
||||
#FLUXER_APP_ICON_URL=
|
||||
#FLUXER_APP_SYMBOL_URL=
|
||||
#FLUXER_APP_LOGO_URL=
|
||||
#FLUXER_APP_WORDMARK_URL=
|
||||
#FLUXER_APP_FAVICON_URL=
|
||||
#FLUXER_APP_THEME_COLOR=
|
||||
#FLUXER_APP_STATUS_PAGE_URL=
|
||||
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
|
||||
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
|
||||
#FLUXER_AUTO_JOIN_INVITE_CODE=
|
||||
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
|
||||
|
||||
# Sign in with Bluesky, off unless turned on.
|
||||
#FLUXER_AUTH_BLUESKY_ENABLED=false
|
||||
#FLUXER_AUTH_BLUESKY_CLIENT_NAME=Fluxer
|
||||
#FLUXER_AUTH_BLUESKY_CLIENT_URI=
|
||||
#FLUXER_AUTH_BLUESKY_LOGO_URI=
|
||||
#FLUXER_AUTH_BLUESKY_TOS_URI=
|
||||
#FLUXER_AUTH_BLUESKY_POLICY_URI=
|
||||
#FLUXER_AUTH_BLUESKY_KEYS=
|
||||
|
||||
# Public addresses. Each follows the public origin unless set here.
|
||||
#FLUXER_API_ENDPOINT=
|
||||
#FLUXER_API_CLIENT_ENDPOINT=
|
||||
#FLUXER_APP_ENDPOINT=
|
||||
#FLUXER_GATEWAY_ENDPOINT=
|
||||
#FLUXER_MEDIA_ENDPOINT=
|
||||
#FLUXER_STATIC_CDN_ENDPOINT=
|
||||
#FLUXER_ADMIN_ENDPOINT=
|
||||
#FLUXER_MARKETING_ENDPOINT=
|
||||
#FLUXER_INVITE_ENDPOINT=
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
|
||||
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
|
||||
|
||||
# Extra hosts for static assets, invites, gifts and the web app. Empty by default.
|
||||
#FLUXER_STATIC_CDN_DOMAIN=
|
||||
#FLUXER_INVITE_DOMAIN=
|
||||
#FLUXER_GIFT_DOMAIN=
|
||||
#FLUXER_APP_ORIGIN_ALIASES=
|
||||
|
||||
# The path the admin panel is served under. The edge and the admin service read
|
||||
# it. The api follows it through the default FLUXER_ADMIN_ENDPOINT, and not when
|
||||
# FLUXER_ADMIN_ENDPOINT is set. Write it with a leading slash and no trailing
|
||||
# slash.
|
||||
#FLUXER_ADMIN_BASE_PATH=/admin
|
||||
|
||||
# The compression the edge offers, as Caddy encode arguments.
|
||||
#FLUXER_EDGE_ENCODE=zstd gzip
|
||||
|
||||
# Images of the bundled services, for a mirror or another tag. A new Postgres
|
||||
# major needs a dump and restore, as the upgrade guide describes.
|
||||
#FLUXER_CADDY_IMAGE=caddy:2.11-alpine
|
||||
#FLUXER_POSTGRES_IMAGE=postgres:16-alpine
|
||||
#FLUXER_VALKEY_IMAGE=valkey/valkey:9.1-alpine
|
||||
#FLUXER_NATS_IMAGE=nats:2.14-alpine
|
||||
#FLUXER_MEILISEARCH_IMAGE=getmeili/meilisearch:v1.53
|
||||
#FLUXER_SEAWEEDFS_IMAGE=chrislusf/seaweedfs:4.47
|
||||
#FLUXER_LIVEKIT_IMAGE=livekit/livekit-server:v1.12.0
|
||||
|
||||
# Restart policy for every long-running service.
|
||||
#FLUXER_RESTART_POLICY=unless-stopped
|
||||
|
||||
# Health checks. Raise the retries or start periods on a slow host.
|
||||
#FLUXER_HEALTHCHECK_INTERVAL=10s
|
||||
#FLUXER_HEALTHCHECK_TIMEOUT=5s
|
||||
#FLUXER_HEALTHCHECK_RETRIES=10
|
||||
#FLUXER_APP_HEALTHCHECK_RETRIES=30
|
||||
#FLUXER_APP_HEALTHCHECK_START_PERIOD=90s
|
||||
#FLUXER_SVC_HEALTHCHECK_START_PERIOD=60s
|
||||
#FLUXER_WORKER_HEALTHCHECK_RETRIES=3
|
||||
#FLUXER_SEAWEEDFS_HEALTHCHECK_RETRIES=20
|
||||
#FLUXER_SEAWEEDFS_HEALTHCHECK_START_PERIOD=60s
|
||||
#FLUXER_SEAWEEDFS_INIT_ATTEMPTS=60
|
||||
|
||||
# Container memory. These are ceilings, not allocations, and the defaults suit a
|
||||
# 16 GB host. The reservations bias the kernel away from reclaiming from services
|
||||
@@ -268,18 +440,31 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
#FLUXER_MEILISEARCH_ENV=production
|
||||
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
|
||||
|
||||
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
|
||||
# upload burst gets the container OOM-killed. Keep it near three quarters of
|
||||
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
#FLUXER_SEAWEEDFS_TELEMETRY=false
|
||||
|
||||
# Node sizes its heap from the container limit by default. Leave these unset
|
||||
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||
# container OOM-killed instead of reporting a heap error.
|
||||
# container OOM-killed instead of reporting a heap error. The values below are
|
||||
# examples.
|
||||
#FLUXER_API_NODE_HEAP_MB=1792
|
||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||
|
||||
# Extra Node flags for api and worker, appended to NODE_OPTIONS. Empty by
|
||||
# default. The value below is an example.
|
||||
#FLUXER_API_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
|
||||
#FLUXER_WORKER_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
|
||||
|
||||
# Extra CA certificates api and worker trust, as a PEM bundle path inside the
|
||||
# container. The default is the image's system bundle.
|
||||
#FLUXER_NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
|
||||
# is the server setting, not the per-service pool sizes.
|
||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||
@@ -289,23 +474,81 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||
#FLUXER_POSTGRES_RANDOM_PAGE_COST=1.1
|
||||
#FLUXER_POSTGRES_EFFECTIVE_IO_CONCURRENCY=200
|
||||
#FLUXER_POSTGRES_DEFAULT_STATISTICS_TARGET=200
|
||||
#FLUXER_POSTGRES_JIT=off
|
||||
#FLUXER_POSTGRES_MIN_WAL_SIZE=512MB
|
||||
#FLUXER_POSTGRES_MAX_WAL_SIZE=2GB
|
||||
#FLUXER_POSTGRES_CHECKPOINT_COMPLETION_TARGET=0.9
|
||||
#FLUXER_POSTGRES_WAL_BUFFERS=16MB
|
||||
#FLUXER_POSTGRES_WAL_COMPRESSION=zstd
|
||||
#FLUXER_POSTGRES_BGWRITER_DELAY=50ms
|
||||
#FLUXER_POSTGRES_BGWRITER_LRU_MAXPAGES=1000
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_SCALE_FACTOR=0.05
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_ANALYZE_SCALE_FACTOR=0.02
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_COST_LIMIT=2000
|
||||
#FLUXER_POSTGRES_TRACK_IO_TIMING=on
|
||||
#FLUXER_POSTGRES_SHARED_PRELOAD_LIBRARIES=pg_stat_statements
|
||||
|
||||
# Postgres pool size of each service that opens a pool.
|
||||
#FLUXER_API_POSTGRES_MAX_CONNECTIONS=25
|
||||
#FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS=25
|
||||
#FLUXER_USERS_SHARD_POSTGRES_MAX_CONNECTIONS=20
|
||||
#FLUXER_MESSAGES_SHARD_POSTGRES_MAX_CONNECTIONS=20
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||
# append-only file and with noeviction, which fails an over-limit write instead
|
||||
# of dropping queued work. Change the policy only if that state lives elsewhere.
|
||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||
#FLUXER_VALKEY_APPENDFSYNC=everysec
|
||||
|
||||
# The gateway derives its scheduler count from the CPU quota, clamped here. One
|
||||
# scheduler lets a single blocking operation stall every websocket on the node.
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
# A fixed scheduler count skips the clamp. Dirty CPU schedulers default to two
|
||||
# thirds of it.
|
||||
#FLUXER_ERLANG_SCHEDULERS=
|
||||
#FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS=
|
||||
|
||||
# In-flight request ceiling for the users and messages routers and their shards.
|
||||
# One value replaces the built-in default on all of them, so size it for the
|
||||
# busiest. Too low a value rejects requests rather than slowing them, and the api
|
||||
# turns that into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
# Gateway push and RPC tuning.
|
||||
#FLUXER_GATEWAY_PUSH_ENABLED=true
|
||||
#FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED=true
|
||||
#FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS=100000
|
||||
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES=128
|
||||
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
|
||||
#FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY=512
|
||||
#FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS=512
|
||||
#FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD=6
|
||||
#FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS=15000
|
||||
|
||||
# In-flight request ceiling for every svc router and shard. Unset, each keeps its
|
||||
# own default: 192 for messages, 320 for snowflakes and 64 for the rest. One value
|
||||
# replaces all of them, so size it for the busiest. Too low a value rejects
|
||||
# requests rather than slowing them, and the api turns that into a 503. The value
|
||||
# below is an example.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=320
|
||||
|
||||
# svc caches, and how the api calls the svc services over NATS.
|
||||
#FLUXER_SVC_CACHE_MAX_ENTRIES=100000
|
||||
#FLUXER_SVC_CACHE_TTL_MS=30000
|
||||
#FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=536870912
|
||||
#FLUXER_GIF_SERVICE_NATS_CLIENT_NAME=fluxer-api-gifs
|
||||
#FLUXER_GIF_SERVICE_TIMEOUT_MS=12000
|
||||
#FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS=3000
|
||||
#FLUXER_USERS_SERVICE_NATS_CLIENT_NAME=fluxer-api-users
|
||||
#FLUXER_USERS_SERVICE_TIMEOUT_MS=6000
|
||||
#FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES=10000
|
||||
#FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME=fluxer-api-snowflakes
|
||||
#FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE=128
|
||||
#FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK=
|
||||
#FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS=5000
|
||||
#FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS=6000
|
||||
|
||||
# Worker concurrency per lane, as a JSON object keyed by lane.
|
||||
#FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES=
|
||||
|
||||
# Named prepared statements need a session that outlives the transaction, so set
|
||||
# this to false behind a transaction-pooling connection pooler. The bundled
|
||||
@@ -317,3 +560,51 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
# is clamped down to the second. Milliseconds, 1000 to 3600000.
|
||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
# api request limits and IP bans. A refresh interval of 0 stops the periodic
|
||||
# ban reload.
|
||||
#FLUXER_API_MAX_INFLIGHT_REQUESTS=512
|
||||
#FLUXER_API_IP_BAN_EXEMPT_IPS=
|
||||
#FLUXER_IP_BAN_REFRESH_INTERVAL_MS=300000
|
||||
|
||||
# Uploads and data exports. Presigned exports link to FLUXER_S3_PUBLIC_ENDPOINT,
|
||||
# so turn them on only once browsers can reach it.
|
||||
#FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
||||
#FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED=false
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES=524288000
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS=900
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES=
|
||||
#FLUXER_API_STORAGE_CHANGE_FEED_ENABLED=false
|
||||
#FLUXER_API_STORAGE_CHANGE_FEED_STREAM=STORAGE_CHANGES
|
||||
#FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS=
|
||||
#FLUXER_CACHE_PURGE_ADAPTER=none
|
||||
#FLUXER_CACHE_PURGE_HTTP_ENDPOINT=
|
||||
#FLUXER_CACHE_PURGE_HTTP_TOKEN=
|
||||
#FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS=10000
|
||||
|
||||
# media-proxy limits and timeouts.
|
||||
#FLUXER_MEDIA_PROXY_READ_ONLY=false
|
||||
#FLUXER_MEDIA_PROXY_NSFW_THRESHOLD=0.85
|
||||
#FLUXER_NSFW_SERVICE_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS=
|
||||
#FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY=
|
||||
#FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS=30000
|
||||
#FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES=20000
|
||||
#FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS=15000
|
||||
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES=268435456
|
||||
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES=67108864
|
||||
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS=120000
|
||||
#FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS=30000
|
||||
#FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS=30000
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS=900000
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES=33554432
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES=536870912
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
|
||||
|
||||
# app-proxy discovery refresh, index upstream and manifest scope.
|
||||
#DISCOVERY_REFRESH_INTERVAL_MS=60000
|
||||
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
|
||||
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
|
||||
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
{$FLUXER_EDGE_SITE_ADDRESS} {
|
||||
encode zstd gzip
|
||||
encode {$FLUXER_EDGE_ENCODE:zstd gzip}
|
||||
|
||||
handle /_health {
|
||||
respond "OK" 200
|
||||
@@ -33,12 +33,12 @@
|
||||
reverse_proxy livekit:7880
|
||||
}
|
||||
|
||||
handle /admin {
|
||||
handle {$FLUXER_ADMIN_BASE_PATH:/admin} {
|
||||
rewrite * /
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
handle_path /admin/* {
|
||||
handle_path {$FLUXER_ADMIN_BASE_PATH:/admin}/* {
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
|
||||
@@ -3,40 +3,82 @@ name: fluxer
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
|
||||
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
FLUXER_POSTGRES_PORT: ${FLUXER_POSTGRES_PORT:-}
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-}
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
FLUXER_POSTGRES_URL: ${FLUXER_POSTGRES_URL:-}
|
||||
FLUXER_POSTGRES_SSL: ${FLUXER_POSTGRES_SSL:-}
|
||||
FLUXER_POSTGRES_SSL_CA: ${FLUXER_POSTGRES_SSL_CA:-}
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-}
|
||||
FLUXER_POSTGRES_KV_TABLE: ${FLUXER_POSTGRES_KV_TABLE:-}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-}
|
||||
RUST_LOG: ${RUST_LOG:-}
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-}
|
||||
FLUXER_API_MAX_INFLIGHT_REQUESTS: ${FLUXER_API_MAX_INFLIGHT_REQUESTS:-}
|
||||
FLUXER_API_IP_BAN_EXEMPT_IPS: ${FLUXER_API_IP_BAN_EXEMPT_IPS:-}
|
||||
FLUXER_IP_BAN_REFRESH_INTERVAL_MS: ${FLUXER_IP_BAN_REFRESH_INTERVAL_MS:-}
|
||||
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
|
||||
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
|
||||
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
|
||||
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
|
||||
FLUXER_API_CLIENT_ENDPOINT: ${FLUXER_API_CLIENT_ENDPOINT:-}
|
||||
FLUXER_APP_ENDPOINT: ${FLUXER_APP_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
FLUXER_GATEWAY_ENDPOINT: ${FLUXER_GATEWAY_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT:-${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_ADMIN_ENDPOINT: ${FLUXER_ADMIN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}${FLUXER_ADMIN_BASE_PATH:-/admin}}
|
||||
FLUXER_MARKETING_ENDPOINT: ${FLUXER_MARKETING_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
FLUXER_INVITE_ENDPOINT: ${FLUXER_INVITE_ENDPOINT:-}
|
||||
FLUXER_GIFT_ENDPOINT: ${FLUXER_GIFT_ENDPOINT:-}
|
||||
FLUXER_STATIC_CDN_DOMAIN: ${FLUXER_STATIC_CDN_DOMAIN:-}
|
||||
FLUXER_INVITE_DOMAIN: ${FLUXER_INVITE_DOMAIN:-}
|
||||
FLUXER_GIFT_DOMAIN: ${FLUXER_GIFT_DOMAIN:-}
|
||||
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_KV_MODE: ${FLUXER_KV_MODE:-}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}
|
||||
FLUXER_SVC_CACHE_MAX_ENTRIES: ${FLUXER_SVC_CACHE_MAX_ENTRIES:-}
|
||||
FLUXER_SVC_CACHE_TTL_MS: ${FLUXER_SVC_CACHE_TTL_MS:-}
|
||||
FLUXER_GIF_SERVICE_NATS_CLIENT_NAME: ${FLUXER_GIF_SERVICE_NATS_CLIENT_NAME:-}
|
||||
FLUXER_GIF_SERVICE_TIMEOUT_MS: ${FLUXER_GIF_SERVICE_TIMEOUT_MS:-}
|
||||
FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS: ${FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS:-}
|
||||
FLUXER_USERS_SERVICE_NATS_CLIENT_NAME: ${FLUXER_USERS_SERVICE_NATS_CLIENT_NAME:-}
|
||||
FLUXER_USERS_SERVICE_TIMEOUT_MS: ${FLUXER_USERS_SERVICE_TIMEOUT_MS:-}
|
||||
FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES: ${FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES:-}
|
||||
FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME: ${FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME:-}
|
||||
FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE: ${FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE:-}
|
||||
FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK: ${FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK:-}
|
||||
FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS: ${FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS:-}
|
||||
FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS: ${FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS:-}
|
||||
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_ENGINE: ${FLUXER_SEARCH_ENGINE:-meilisearch}
|
||||
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
|
||||
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
FLUXER_SEARCH_USERNAME: ${FLUXER_SEARCH_USERNAME:-}
|
||||
FLUXER_SEARCH_PASSWORD: ${FLUXER_SEARCH_PASSWORD:-}
|
||||
FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED: ${FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED:-}
|
||||
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
|
||||
@@ -48,52 +90,96 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED: "${FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED:-false}"
|
||||
FLUXER_API_STORAGE_CHANGE_FEED_ENABLED: ${FLUXER_API_STORAGE_CHANGE_FEED_ENABLED:-}
|
||||
FLUXER_API_STORAGE_CHANGE_FEED_STREAM: ${FLUXER_API_STORAGE_CHANGE_FEED_STREAM:-}
|
||||
FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS: ${FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS:-}
|
||||
FLUXER_CACHE_PURGE_ADAPTER: ${FLUXER_CACHE_PURGE_ADAPTER:-}
|
||||
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: ${FLUXER_CACHE_PURGE_HTTP_ENDPOINT:-}
|
||||
FLUXER_CACHE_PURGE_HTTP_TOKEN: ${FLUXER_CACHE_PURGE_HTTP_TOKEN:-}
|
||||
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: ${FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS:-}
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '${FLUXER_LIVEKIT_DEFAULT_REGION:-{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
|
||||
FLUXER_API_UNFURL_IGNORED_HOSTS: ${FLUXER_API_UNFURL_IGNORED_HOSTS:-}
|
||||
|
||||
FLUXER_EMAIL_ENABLED: ${FLUXER_EMAIL_ENABLED:-false}
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
|
||||
FLUXER_EMAIL_ENABLED: ${FLUXER_EMAIL_ENABLED:-}
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
|
||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
|
||||
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
|
||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
|
||||
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
|
||||
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
|
||||
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_WEBHOOK_SECRET: ${FLUXER_STRIPE_WEBHOOK_SECRET:-}
|
||||
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
|
||||
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
|
||||
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
|
||||
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
|
||||
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
|
||||
|
||||
FLUXER_NCMEC_ENABLED: ${FLUXER_NCMEC_ENABLED:-}
|
||||
FLUXER_NCMEC_BASE_URL: ${FLUXER_NCMEC_BASE_URL:-}
|
||||
FLUXER_NCMEC_USERNAME: ${FLUXER_NCMEC_USERNAME:-}
|
||||
FLUXER_NCMEC_PASSWORD: ${FLUXER_NCMEC_PASSWORD:-}
|
||||
FLUXER_NCMEC_REPORTER_EMAIL: ${FLUXER_NCMEC_REPORTER_EMAIL:-}
|
||||
FLUXER_CLAMAV_ENABLED: ${FLUXER_CLAMAV_ENABLED:-}
|
||||
FLUXER_CLAMAV_HOST: ${FLUXER_CLAMAV_HOST:-}
|
||||
FLUXER_CLAMAV_PORT: ${FLUXER_CLAMAV_PORT:-}
|
||||
FLUXER_CLAMAV_FAIL_OPEN: ${FLUXER_CLAMAV_FAIL_OPEN:-}
|
||||
|
||||
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
|
||||
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
|
||||
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
|
||||
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
|
||||
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
|
||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
|
||||
|
||||
FLUXER_AUTH_BLUESKY_ENABLED: ${FLUXER_AUTH_BLUESKY_ENABLED:-}
|
||||
FLUXER_AUTH_BLUESKY_CLIENT_NAME: ${FLUXER_AUTH_BLUESKY_CLIENT_NAME:-}
|
||||
FLUXER_AUTH_BLUESKY_CLIENT_URI: ${FLUXER_AUTH_BLUESKY_CLIENT_URI:-}
|
||||
FLUXER_AUTH_BLUESKY_LOGO_URI: ${FLUXER_AUTH_BLUESKY_LOGO_URI:-}
|
||||
FLUXER_AUTH_BLUESKY_TOS_URI: ${FLUXER_AUTH_BLUESKY_TOS_URI:-}
|
||||
FLUXER_AUTH_BLUESKY_POLICY_URI: ${FLUXER_AUTH_BLUESKY_POLICY_URI:-}
|
||||
FLUXER_AUTH_BLUESKY_KEYS: ${FLUXER_AUTH_BLUESKY_KEYS:-}
|
||||
|
||||
FLUXER_PUSH_APNS_ENABLED: ${FLUXER_PUSH_APNS_ENABLED:-}
|
||||
FLUXER_PUSH_APNS_TEAM_ID: ${FLUXER_PUSH_APNS_TEAM_ID:-}
|
||||
FLUXER_PUSH_APNS_KEY_ID: ${FLUXER_PUSH_APNS_KEY_ID:-}
|
||||
FLUXER_PUSH_APNS_PRIVATE_KEY: ${FLUXER_PUSH_APNS_PRIVATE_KEY:-}
|
||||
FLUXER_PUSH_APNS_PRIVATE_KEY_PATH: ${FLUXER_PUSH_APNS_PRIVATE_KEY_PATH:-}
|
||||
FLUXER_PUSH_APNS_APPS: ${FLUXER_PUSH_APNS_APPS:-}
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-false}
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
|
||||
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
|
||||
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
|
||||
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
|
||||
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||
@@ -103,34 +189,36 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
|
||||
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES:-}
|
||||
|
||||
x-fluxer-service: &fluxer-service
|
||||
restart: unless-stopped
|
||||
restart: ${FLUXER_RESTART_POLICY:-unless-stopped}
|
||||
networks: [fluxer]
|
||||
|
||||
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
x-fluxer-app-healthcheck: &fluxer-app-healthcheck
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
|
||||
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
|
||||
start_interval: 1s
|
||||
|
||||
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
<<: *fluxer-app-healthcheck
|
||||
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
|
||||
services:
|
||||
edge:
|
||||
image: caddy:2.11-alpine
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_CADDY_IMAGE:-caddy:2.11-alpine}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
ports:
|
||||
- "${FLUXER_HTTP_PORT:-80}:80"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
@@ -138,15 +226,17 @@ services:
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
FLUXER_EDGE_ENCODE: ${FLUXER_EDGE_ENCODE:-zstd gzip}
|
||||
FLUXER_ADMIN_BASE_PATH: ${FLUXER_ADMIN_BASE_PATH:-/admin}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- edge-data:/data
|
||||
- edge-config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
depends_on:
|
||||
api: {condition: service_started}
|
||||
gateway: {condition: service_healthy}
|
||||
@@ -155,15 +245,14 @@ services:
|
||||
admin: {condition: service_started}
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
|
||||
reservations:
|
||||
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: >
|
||||
postgres
|
||||
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
|
||||
@@ -172,82 +261,79 @@ services:
|
||||
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
|
||||
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
|
||||
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
|
||||
-c random_page_cost=1.1
|
||||
-c effective_io_concurrency=200
|
||||
-c default_statistics_target=200
|
||||
-c jit=off
|
||||
-c min_wal_size=512MB
|
||||
-c max_wal_size=2GB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=16MB
|
||||
-c wal_compression=zstd
|
||||
-c bgwriter_delay=50ms
|
||||
-c bgwriter_lru_maxpages=1000
|
||||
-c autovacuum_vacuum_scale_factor=0.05
|
||||
-c autovacuum_analyze_scale_factor=0.02
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
-c random_page_cost=${FLUXER_POSTGRES_RANDOM_PAGE_COST:-1.1}
|
||||
-c effective_io_concurrency=${FLUXER_POSTGRES_EFFECTIVE_IO_CONCURRENCY:-200}
|
||||
-c default_statistics_target=${FLUXER_POSTGRES_DEFAULT_STATISTICS_TARGET:-200}
|
||||
-c jit=${FLUXER_POSTGRES_JIT:-off}
|
||||
-c min_wal_size=${FLUXER_POSTGRES_MIN_WAL_SIZE:-512MB}
|
||||
-c max_wal_size=${FLUXER_POSTGRES_MAX_WAL_SIZE:-2GB}
|
||||
-c checkpoint_completion_target=${FLUXER_POSTGRES_CHECKPOINT_COMPLETION_TARGET:-0.9}
|
||||
-c wal_buffers=${FLUXER_POSTGRES_WAL_BUFFERS:-16MB}
|
||||
-c wal_compression=${FLUXER_POSTGRES_WAL_COMPRESSION:-zstd}
|
||||
-c bgwriter_delay=${FLUXER_POSTGRES_BGWRITER_DELAY:-50ms}
|
||||
-c bgwriter_lru_maxpages=${FLUXER_POSTGRES_BGWRITER_LRU_MAXPAGES:-1000}
|
||||
-c autovacuum_vacuum_scale_factor=${FLUXER_POSTGRES_AUTOVACUUM_VACUUM_SCALE_FACTOR:-0.05}
|
||||
-c autovacuum_analyze_scale_factor=${FLUXER_POSTGRES_AUTOVACUUM_ANALYZE_SCALE_FACTOR:-0.02}
|
||||
-c autovacuum_vacuum_cost_limit=${FLUXER_POSTGRES_AUTOVACUUM_VACUUM_COST_LIMIT:-2000}
|
||||
-c track_io_timing=${FLUXER_POSTGRES_TRACK_IO_TIMING:-on}
|
||||
-c shared_preload_libraries=${FLUXER_POSTGRES_SHARED_PRELOAD_LIBRARIES:-pg_stat_statements}
|
||||
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
POSTGRES_DB: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
POSTGRES_USER: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:9.1-alpine
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_VALKEY_IMAGE:-valkey/valkey:9.1-alpine}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
|
||||
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "${FLUXER_VALKEY_APPENDFSYNC:-everysec}", "--dir", "/data",
|
||||
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
|
||||
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
nats:
|
||||
image: nats:2.14-alpine
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_NATS_IMAGE:-nats:2.14-alpine}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.53
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_MEILISEARCH_IMAGE:-getmeili/meilisearch:v1.53}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_ENV: ${FLUXER_MEILISEARCH_ENV:-production}
|
||||
MEILI_NO_ANALYTICS: "${FLUXER_MEILISEARCH_NO_ANALYTICS:-true}"
|
||||
MEILI_UPGRADE_DB: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
@@ -255,32 +341,31 @@ services:
|
||||
- meilisearch-data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.47}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 60s
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_SEAWEEDFS_HEALTHCHECK_RETRIES:-20}
|
||||
start_period: ${FLUXER_SEAWEEDFS_HEALTHCHECK_START_PERIOD:-60s}
|
||||
|
||||
seaweedfs-init:
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
image: ${FLUXER_SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.47}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -296,13 +381,14 @@ services:
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
FLUXER_SEAWEEDFS_INIT_ATTEMPTS: ${FLUXER_SEAWEEDFS_INIT_ATTEMPTS:-60}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
for attempt in $$(seq 1 $$FLUXER_SEAWEEDFS_INIT_ATTEMPTS); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
sleep 2;
|
||||
continue;
|
||||
@@ -329,18 +415,17 @@ services:
|
||||
exit 1;
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.12.0
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_LIVEKIT_IMAGE:-livekit/livekit-server:v1.12.0}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
|
||||
LIVEKIT_CONFIG: |
|
||||
port: 7880
|
||||
log_level: info
|
||||
log_level: ${FLUXER_LIVEKIT_LOG_LEVEL:-info}
|
||||
rtc:
|
||||
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
|
||||
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
|
||||
@@ -358,9 +443,9 @@ services:
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
api:
|
||||
<<: *fluxer-service
|
||||
@@ -374,16 +459,13 @@ services:
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_API_PORT: "8080"
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}${FLUXER_API_NODE_OPTIONS:+ $FLUXER_API_NODE_OPTIONS}
|
||||
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_API_POSTGRES_MAX_CONNECTIONS:-25}"
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "${FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED:-true}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
start_interval: 1s
|
||||
<<: *fluxer-app-healthcheck
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
@@ -408,21 +490,18 @@ services:
|
||||
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
|
||||
reservations:
|
||||
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
|
||||
working_dir: /usr/src/app/fluxer_api
|
||||
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
|
||||
command: ["node", "dist/WorkerEntrypoint.js"]
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}${FLUXER_WORKER_NODE_OPTIONS:+ $FLUXER_WORKER_NODE_OPTIONS}
|
||||
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS:-25}"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
start_interval: 1s
|
||||
<<: *fluxer-app-healthcheck
|
||||
retries: ${FLUXER_WORKER_HEALTHCHECK_RETRIES:-3}
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
@@ -444,18 +523,30 @@ services:
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_GATEWAY_PORT: "8080"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_GATEWAY_LOGGER_LEVEL: info
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT:-${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}}
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_GATEWAY_STATIC_CDN_ENDPOINT:-${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}}
|
||||
FLUXER_GATEWAY_LOGGER_LEVEL: ${FLUXER_GATEWAY_LOGGER_LEVEL:-}
|
||||
LOGGER_LEVEL: ${LOGGER_LEVEL:-}
|
||||
FLUXER_GATEWAY_PUSH_ENABLED: ${FLUXER_GATEWAY_PUSH_ENABLED:-}
|
||||
FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED: ${FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED:-}
|
||||
FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS: ${FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS:-}
|
||||
FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES: ${FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES:-}
|
||||
FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES: ${FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES:-}
|
||||
FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY: ${FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY:-}
|
||||
FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS: ${FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS:-}
|
||||
FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD: ${FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD:-}
|
||||
FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS: ${FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS:-}
|
||||
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
|
||||
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
|
||||
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
|
||||
FLUXER_ERLANG_SCHEDULERS: ${FLUXER_ERLANG_SCHEDULERS:-}
|
||||
FLUXER_ERLANG_SCHEDULERS_MIN: ${FLUXER_ERLANG_SCHEDULERS_MIN:-}
|
||||
FLUXER_ERLANG_SCHEDULERS_MAX: ${FLUXER_ERLANG_SCHEDULERS_MAX:-}
|
||||
FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS: ${FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
|
||||
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
@@ -469,15 +560,36 @@ services:
|
||||
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_MEDIA_PROXY_PORT: "8080"
|
||||
FLUXER_MEDIA_PROXY_MODE: upload
|
||||
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-off}
|
||||
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-}
|
||||
FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS: ${FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}}
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE: ${FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE:-off}
|
||||
FLUXER_S3_READ_SIGNED: "true"
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE: ${FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE:-}
|
||||
FLUXER_MEDIA_PROXY_READ_ONLY: ${FLUXER_MEDIA_PROXY_READ_ONLY:-}
|
||||
FLUXER_MEDIA_PROXY_NSFW_THRESHOLD: ${FLUXER_MEDIA_PROXY_NSFW_THRESHOLD:-}
|
||||
FLUXER_NSFW_SERVICE_ENDPOINT: ${FLUXER_NSFW_SERVICE_ENDPOINT:-}
|
||||
FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS: ${FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS:-}
|
||||
FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY: ${FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY:-}
|
||||
FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS: ${FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS:-}
|
||||
FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES: ${FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES:-}
|
||||
FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS:-}
|
||||
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS:-}
|
||||
FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS:-}
|
||||
FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS: ${FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES:-}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES:-}
|
||||
FLUXER_S3_SESSION_TOKEN: ${FLUXER_S3_SESSION_TOKEN:-}
|
||||
FLUXER_S3_READ_ENDPOINT: ${FLUXER_S3_READ_ENDPOINT:-}
|
||||
FLUXER_S3_READ_BUCKET: ${FLUXER_S3_READ_BUCKET:-}
|
||||
FLUXER_S3_READ_BUCKET_STYLE: ${FLUXER_S3_READ_BUCKET_STYLE:-}
|
||||
FLUXER_S3_READ_SIGNED: "${FLUXER_S3_READ_SIGNED:-true}"
|
||||
depends_on:
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
nats: {condition: service_healthy}
|
||||
@@ -491,17 +603,26 @@ services:
|
||||
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
|
||||
FLUXER_PUSH_SERVICE_PORT: "8126"
|
||||
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
|
||||
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
|
||||
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: ${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}
|
||||
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: ${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}
|
||||
FLUXER_PUSH_SERVICE_APNS_BASE_URL: ${FLUXER_PUSH_SERVICE_APNS_BASE_URL:-}
|
||||
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
|
||||
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
|
||||
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
|
||||
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
|
||||
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
|
||||
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
|
||||
FLUXER_PUSH_FCM_PROJECT_ID: ${FLUXER_PUSH_FCM_PROJECT_ID:-}
|
||||
FLUXER_PUSH_FCM_CLIENT_EMAIL: ${FLUXER_PUSH_FCM_CLIENT_EMAIL:-}
|
||||
FLUXER_PUSH_FCM_PRIVATE_KEY: ${FLUXER_PUSH_FCM_PRIVATE_KEY:-}
|
||||
FLUXER_PUSH_FCM_PRIVATE_KEY_PATH: ${FLUXER_PUSH_FCM_PRIVATE_KEY_PATH:-}
|
||||
FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH: ${FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH:-}
|
||||
FLUXER_PUSH_FCM_TOKEN_URI: ${FLUXER_PUSH_FCM_TOKEN_URI:-}
|
||||
FLUXER_PUSH_FCM_APPS: ${FLUXER_PUSH_FCM_APPS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
start_interval: 1s
|
||||
<<: *fluxer-app-healthcheck
|
||||
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
api: {condition: service_healthy}
|
||||
@@ -515,9 +636,9 @@ services:
|
||||
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
|
||||
|
||||
app-proxy:
|
||||
<<: *fluxer-service
|
||||
@@ -527,15 +648,29 @@ services:
|
||||
limits:
|
||||
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
RUST_LOG: ${RUST_LOG:-}
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
|
||||
@@ -593,7 +728,6 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -610,8 +744,7 @@ services:
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_USERS_SHARD_POSTGRES_MAX_CONNECTIONS:-20}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -628,7 +761,6 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: gifs
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -645,7 +777,7 @@ services:
|
||||
FLUXER_SVC_NAME: gifs
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_GIFS_SHARD_CACHE_MAX_BYTES: ${FLUXER_GIFS_SHARD_CACHE_MAX_BYTES:-}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -661,7 +793,6 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -678,8 +809,7 @@ services:
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_MESSAGES_SHARD_POSTGRES_MAX_CONNECTIONS:-20}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -696,8 +826,6 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -714,8 +842,9 @@ services:
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -729,22 +858,14 @@ services:
|
||||
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_ADMIN_HOST: 0.0.0.0
|
||||
FLUXER_ADMIN_PORT: "8080"
|
||||
FLUXER_ADMIN_BASE_PATH: /admin
|
||||
FLUXER_ADMIN_BASE_PATH: ${FLUXER_ADMIN_BASE_PATH:-/admin}
|
||||
FLUXER_API_ENDPOINT: http://api:8080
|
||||
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
|
||||
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
start_interval: 1s
|
||||
<<: *fluxer-app-healthcheck
|
||||
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
seaweedfs:
|
||||
profiles: [bundled-object-store]
|
||||
seaweedfs-init:
|
||||
profiles: [bundled-object-store]
|
||||
api:
|
||||
depends_on:
|
||||
seaweedfs-init: !reset null
|
||||
worker:
|
||||
depends_on:
|
||||
seaweedfs-init: !reset null
|
||||
media-proxy:
|
||||
depends_on:
|
||||
seaweedfs-init: !reset null
|
||||
@@ -26,7 +26,7 @@ tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "s
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
tracing-subscriber = "0.3.23"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1.3"
|
||||
progenitor-client = { version = "0.15.0", default-features = false }
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
FROM rust:1-trixie AS builder
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
@@ -45,8 +44,6 @@ RUN printf '%s\n' \
|
||||
'strip = "symbols"' \
|
||||
> Cargo.toml
|
||||
|
||||
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
|
||||
|
||||
RUN cargo build --release -p fluxer_admin \
|
||||
&& cp target/release/fluxer_admin /usr/local/bin/fluxer-admin
|
||||
|
||||
|
||||
+199
-259
@@ -5205,7 +5205,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.",
|
||||
"description": "Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter by system DM and email unless notify_reporter is false, and creates an audit log entry. Requires REPORT_RESOLVE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5993,7 +5993,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.",
|
||||
"description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission. Emails the user for temporary bans unless notify_user is false. Permanent bans are never emailed.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6057,7 +6057,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.",
|
||||
"description": "Immediately remove the ban from the user account. Emails the user only when notify_user is true, the ban was still in force and the account is not closed or pending deletion. The email shows public_reason and never the audit log reason. Creates audit log entry. Requires USER_TEMP_BAN permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6067,7 +6067,74 @@
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
]
|
||||
],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserUnbanRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/ban/notes": {
|
||||
"post": {
|
||||
"operationId": "annotate_admin_user_ban",
|
||||
"summary": "Add a note to a user ban",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Append a note to the current ban of a user. The note is recorded as the reason of a new annotate_ban audit log entry whose metadata names the ban audit log entry. Earlier entries are never changed. Requires USER_TEMP_BAN permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBanNoteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/bot-status": {
|
||||
@@ -6329,7 +6396,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.",
|
||||
"description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission. Emails the user unless notify_user is false. The email depends on reason_code: user requested and inactivity get neutral wording, other codes get enforcement wording with an appeal path.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6393,7 +6460,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.",
|
||||
"description": "Cancel the scheduled account deletion named by expected_pending_deletion_at. Returns 409 when a different deletion is pending and 400 when none is. The user is emailed only when notify_user is true, and the email never includes the audit log reason. Creates audit log entry recording the cancelled deletion. Requires USER_DELETE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6403,7 +6470,11 @@
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
]
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserDeletionCancelRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/dm-channels": {
|
||||
@@ -7705,7 +7776,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.",
|
||||
"description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Emails the user unless notify_user is false. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -9327,6 +9398,11 @@
|
||||
"flags": {
|
||||
"description": "Bitmask of suspicious activity flags that triggered the disable",
|
||||
"allOf": [{"$ref": "#/components/schemas/SuspiciousActivityFlags"}]
|
||||
},
|
||||
"notify_user": {
|
||||
"default": true,
|
||||
"description": "Whether to email the user that the account was disabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["flags"]
|
||||
@@ -9702,6 +9778,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AdminUserDeletionCancelRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expected_pending_deletion_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
|
||||
"description": "pending_deletion_at of the deletion being cancelled, as shown on the account"
|
||||
},
|
||||
"notify_user": {
|
||||
"default": false,
|
||||
"description": "Whether to email the user that the deletion was cancelled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["expected_pending_deletion_at"]
|
||||
},
|
||||
"AdminUserDeletionScheduleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9716,6 +9809,17 @@
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365
|
||||
},
|
||||
"replace_pending_deletion_at": {
|
||||
"description": "pending_deletion_at of the deletion this request replaces. Required when a deletion is already scheduled for the account",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"notify_user": {
|
||||
"default": true,
|
||||
"description": "Whether to email the user about the scheduled deletion",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["reason_code"]
|
||||
@@ -9756,6 +9860,34 @@
|
||||
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
|
||||
"required": ["bot"]
|
||||
},
|
||||
"AdminUserBanNoteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ban_audit_log_id": {
|
||||
"description": "Audit log entry of the current ban that the note refers to",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"note": {
|
||||
"description": "Note to append to the ban. Recorded as the reason of a new audit log entry",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["ban_audit_log_id", "note"]
|
||||
},
|
||||
"AdminUserUnbanRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notify_user": {
|
||||
"default": true,
|
||||
"description": "Whether to email the user that the suspension was lifted",
|
||||
"type": "boolean"
|
||||
},
|
||||
"public_reason": {
|
||||
"description": "Reason shown to the user in the unban email. The audit log reason is never emailed",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdminUserBanRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9765,7 +9897,12 @@
|
||||
"maximum": 8760,
|
||||
"description": "Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned)."
|
||||
},
|
||||
"reason": {"description": "Reason for the temporary ban", "type": "string"}
|
||||
"reason": {"description": "Reason shown to the user in the ban email", "type": "string"},
|
||||
"notify_user": {
|
||||
"default": true,
|
||||
"description": "Whether to email the user about a temporary ban. Permanent bans (duration_hours 0) are never emailed",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["duration_hours"]
|
||||
},
|
||||
@@ -9837,7 +9974,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -9955,7 +10092,12 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["resolved"], "description": "The status to move the report to"},
|
||||
"public_comment": {"description": "Public comment to include with the resolution", "type": "string"}
|
||||
"public_comment": {"description": "Public comment to include with the resolution", "type": "string"},
|
||||
"notify_reporter": {
|
||||
"default": true,
|
||||
"description": "Whether to notify the reporter by system DM and email",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["status"]
|
||||
},
|
||||
@@ -10525,8 +10667,7 @@
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
|
||||
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10719,16 +10860,6 @@
|
||||
},
|
||||
"required": ["gif", "youtube", "bluesky"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number"},
|
||||
"member_threshold": {"type": "number"}
|
||||
},
|
||||
"required": ["enabled", "window_hours", "member_threshold"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10739,8 +10870,7 @@
|
||||
"premium_mode",
|
||||
"services",
|
||||
"services_resolved",
|
||||
"services_available",
|
||||
"deferred_phone_gate"
|
||||
"services_available"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -10759,31 +10889,6 @@
|
||||
"required": ["api_key_set", "effective_available"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"captcha": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"}]
|
||||
},
|
||||
"effective_provider": {"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"},
|
||||
"hcaptcha_site_key": {"nullable": true, "type": "string"},
|
||||
"hcaptcha_secret_key_set": {"type": "boolean"},
|
||||
"turnstile_site_key": {"nullable": true, "type": "string"},
|
||||
"turnstile_secret_key_set": {"type": "boolean"},
|
||||
"effective_enabled": {"type": "boolean"}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"effective_provider",
|
||||
"hcaptcha_site_key",
|
||||
"hcaptcha_secret_key_set",
|
||||
"turnstile_site_key",
|
||||
"turnstile_secret_key_set",
|
||||
"effective_enabled"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"email": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10846,7 +10951,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["gif", "youtube", "captcha", "email", "bluesky"],
|
||||
"required": ["gif", "youtube", "email", "bluesky"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"media": {
|
||||
@@ -10960,8 +11065,7 @@
|
||||
"gateway_rollout",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"captcha",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11098,14 +11202,7 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -11188,20 +11285,6 @@
|
||||
"type": "object",
|
||||
"properties": {"api_key": {"nullable": true, "type": "string", "maxLength": 4096}}
|
||||
},
|
||||
"captcha": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"}]
|
||||
},
|
||||
"hcaptcha_site_key": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"hcaptcha_secret_key": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"turnstile_site_key": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"turnstile_secret_key": {"nullable": true, "type": "string", "maxLength": 4096}
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -11312,15 +11395,6 @@
|
||||
"youtube_enabled": {"nullable": true, "type": "boolean"},
|
||||
"bluesky_enabled": {"nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number", "minimum": 0, "exclusiveMinimum": true, "maximum": 8760},
|
||||
"member_threshold": {"type": "integer", "minimum": 0, "exclusiveMinimum": true, "maximum": 1000000}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12282,6 +12356,11 @@
|
||||
"minimum": 1,
|
||||
"maximum": 365
|
||||
},
|
||||
"notify_user": {
|
||||
"default": true,
|
||||
"description": "Whether to email the user about the scheduled deletion",
|
||||
"type": "boolean"
|
||||
},
|
||||
"user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
@@ -12323,17 +12402,7 @@
|
||||
},
|
||||
"AdminBlocklistListType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ip",
|
||||
"email",
|
||||
"email-domain-suspicious",
|
||||
"phrase",
|
||||
"url",
|
||||
"url-domain",
|
||||
"file-sha",
|
||||
"avatar-hash",
|
||||
"profile-substring"
|
||||
],
|
||||
"enum": ["ip", "email", "phrase", "url", "url-domain", "file-sha", "avatar-hash", "profile-substring"],
|
||||
"description": "The blocklist an entry belongs to"
|
||||
},
|
||||
"AdminBlocklistEntryUpdateRequest": {
|
||||
@@ -12376,7 +12445,6 @@
|
||||
"anyOf": [
|
||||
{"$ref": "#/components/schemas/BanIpRequest"},
|
||||
{"$ref": "#/components/schemas/BanEmailRequest"},
|
||||
{"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"},
|
||||
{"$ref": "#/components/schemas/BanPhraseRequest"},
|
||||
{"$ref": "#/components/schemas/BanUrlRequest"},
|
||||
{"$ref": "#/components/schemas/BanUrlDomainRequest"},
|
||||
@@ -12753,7 +12821,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -12771,7 +12839,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12801,7 +12869,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12822,7 +12890,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12835,7 +12903,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 111,
|
||||
"maxItems": 108,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -12864,9 +12932,6 @@
|
||||
"ban:email:add",
|
||||
"ban:email:check",
|
||||
"ban:email:remove",
|
||||
"suspicious_email_domain:add",
|
||||
"suspicious_email_domain:check",
|
||||
"suspicious_email_domain:remove",
|
||||
"ban:phrase:add",
|
||||
"ban:phrase:check",
|
||||
"ban:phrase:remove",
|
||||
@@ -13179,19 +13244,6 @@
|
||||
},
|
||||
"required": ["phrase"]
|
||||
},
|
||||
"SuspiciousEmailDomainRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 253,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}$",
|
||||
"description": "Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number."
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"BanEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13434,12 +13486,12 @@
|
||||
{
|
||||
"name": "FORCE_INBOUND_PHONE_VERIFICATION",
|
||||
"value": "2305843009213693952",
|
||||
"description": "User is forced through inbound (expensive-destination) phone verification regardless of phone prefix, for debugging"
|
||||
"description": "User is forced through inbound phone verification, for debugging"
|
||||
},
|
||||
{
|
||||
"name": "NOT_SUSPICIOUS",
|
||||
"value": "4611686018427387904",
|
||||
"description": "User is permanently exempt from automatic suspicious-activity flagging on RPC session start (does not require a prior payment)"
|
||||
"description": "User is permanently exempt from automatic suspicious-activity flagging"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -15205,7 +15257,6 @@
|
||||
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
|
||||
}
|
||||
},
|
||||
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
|
||||
"InstanceRegistrationModeSchema": {
|
||||
"description": "Registration mode",
|
||||
"x-enumNames": ["open", "approval", "closed"],
|
||||
@@ -15224,55 +15275,12 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ProfileTimezoneConfigUpdateRequest": {
|
||||
"CaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"type": "boolean"},
|
||||
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
"cost": {"type": "integer", "minimum": 1000, "maximum": 20000},
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
@@ -15404,100 +15412,14 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProfileTimezoneConfigResponse": {
|
||||
"CaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "profile-timezone-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
"enabled": {"default": true, "type": "boolean"},
|
||||
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 20000},
|
||||
"max_counter": {"default": 1000, "type": "integer", "minimum": 100, "maximum": 20000}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "altcha-captcha-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"default": false, "type": "boolean"},
|
||||
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
"max_counter"
|
||||
],
|
||||
"required": ["enabled", "cost", "max_counter"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
@@ -15703,14 +15625,29 @@
|
||||
"suspicious_activity_flags": {"allOf": [{"$ref": "#/components/schemas/SuspiciousActivityFlags"}]},
|
||||
"phone_verification_deferred": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
|
||||
"description": "Whether a stored phone requirement is deferred and not enforced"
|
||||
},
|
||||
"temp_banned_until": {"nullable": true, "type": "string"},
|
||||
"pending_deletion_at": {"nullable": true, "type": "string"},
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"deletion_public_reason": {"nullable": true, "type": "string"},
|
||||
"acls": {"maxItems": 111, "type": "array", "items": {"type": "string"}},
|
||||
"deletion_audit_log_reason": {
|
||||
"nullable": true,
|
||||
"description": "Private reason recorded with the pending deletion, null without the audit log view permission",
|
||||
"type": "string"
|
||||
},
|
||||
"deletion_scheduled_by": {
|
||||
"nullable": true,
|
||||
"description": "ID of the account that scheduled the pending deletion, null when it was not recorded",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
|
||||
},
|
||||
"deletion_scheduled_at": {
|
||||
"nullable": true,
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 108, "type": "array", "items": {"type": "string"}},
|
||||
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||
@@ -15751,6 +15688,9 @@
|
||||
"pending_bulk_message_deletion_at",
|
||||
"deletion_reason_code",
|
||||
"deletion_public_reason",
|
||||
"deletion_audit_log_reason",
|
||||
"deletion_scheduled_by",
|
||||
"deletion_scheduled_at",
|
||||
"acls",
|
||||
"traits",
|
||||
"has_totp",
|
||||
|
||||
@@ -17,9 +17,6 @@ pub const JOBS_CANCEL: &str = "jobs:cancel";
|
||||
pub const BAN_EMAIL_ADD: &str = "ban:email:add";
|
||||
pub const BAN_EMAIL_CHECK: &str = "ban:email:check";
|
||||
pub const BAN_EMAIL_REMOVE: &str = "ban:email:remove";
|
||||
pub const SUSPICIOUS_EMAIL_DOMAIN_ADD: &str = "suspicious_email_domain:add";
|
||||
pub const SUSPICIOUS_EMAIL_DOMAIN_CHECK: &str = "suspicious_email_domain:check";
|
||||
pub const SUSPICIOUS_EMAIL_DOMAIN_REMOVE: &str = "suspicious_email_domain:remove";
|
||||
pub const BAN_PHRASE_ADD: &str = "ban:phrase:add";
|
||||
pub const BAN_PHRASE_CHECK: &str = "ban:phrase:check";
|
||||
pub const BAN_PHRASE_REMOVE: &str = "ban:phrase:remove";
|
||||
@@ -129,9 +126,6 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BAN_EMAIL_ADD,
|
||||
BAN_EMAIL_CHECK,
|
||||
BAN_EMAIL_REMOVE,
|
||||
SUSPICIOUS_EMAIL_DOMAIN_ADD,
|
||||
SUSPICIOUS_EMAIL_DOMAIN_CHECK,
|
||||
SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
|
||||
BAN_PHRASE_ADD,
|
||||
BAN_PHRASE_CHECK,
|
||||
BAN_PHRASE_REMOVE,
|
||||
|
||||
@@ -9,12 +9,11 @@ impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"email",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_1: Some(generated_types::BanEmailRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -32,10 +31,9 @@ impl AdminApiClient {
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
|
||||
..Default::default()
|
||||
},
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() },
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -50,45 +48,14 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn add_suspicious_email_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_2: Some(suspicious_email_domain_request(domain)?),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_suspicious_email_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
|
||||
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"phrase",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -110,16 +77,15 @@ impl AdminApiClient {
|
||||
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_4: Some(generated_types::BanUrlRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -142,17 +108,16 @@ impl AdminApiClient {
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url-domain",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -178,17 +143,16 @@ impl AdminApiClient {
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"file-sha",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_6: Some(generated_types::BanFileShaRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -231,17 +195,16 @@ impl AdminApiClient {
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"avatar-hash",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_7: Some(generated_types::BanAvatarHashRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -279,10 +242,9 @@ impl AdminApiClient {
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_8: Some(profile_substring_request(scope, substring)?),
|
||||
..Default::default()
|
||||
},
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(profile_substring_request(
|
||||
scope, substring,
|
||||
)?),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -359,8 +321,6 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
@@ -380,15 +340,6 @@ fn blocklist_delete_scope(
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn suspicious_email_domain_request(
|
||||
domain: &str,
|
||||
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
|
||||
Ok(generated_types::SuspiciousEmailDomainRequest {
|
||||
domain: generated_types::SuspiciousEmailDomainRequestDomain::try_from(domain)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_substring_request(
|
||||
scope: &str,
|
||||
substring: &str,
|
||||
|
||||
@@ -86,6 +86,7 @@ impl AdminApiClient {
|
||||
reason_code: u32,
|
||||
days_until_deletion: u32,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
@@ -95,6 +96,7 @@ impl AdminApiClient {
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
|
||||
@@ -90,10 +90,7 @@ impl AdminApiClient {
|
||||
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
|
||||
let mut headers = self.generated.inner().clone();
|
||||
if let Some(reason) = audit_log_reason {
|
||||
let mut value = HeaderValue::from_str(reason)
|
||||
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert("x-audit-log-reason", value);
|
||||
headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
@@ -422,11 +419,27 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_log_reason_header(reason: &str) -> ApiResult<HeaderValue> {
|
||||
let mut value = HeaderValue::from_bytes(reason.as_bytes())
|
||||
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
|
||||
value.set_sensitive(true);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn audit_log_reason_header_carries_utf8_bytes() {
|
||||
let reason = "§ 3 Regel – wiederholt 日本";
|
||||
let value = audit_log_reason_header(reason).expect("valid reason header");
|
||||
assert_eq!(value.as_bytes(), reason.as_bytes());
|
||||
assert!(value.is_sensitive());
|
||||
assert!(audit_log_reason_header("line one\nline two").is_err());
|
||||
}
|
||||
|
||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||
axum::http::Response::builder()
|
||||
.status(status)
|
||||
|
||||
@@ -56,12 +56,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
report_id: &str,
|
||||
public_comment: Option<&str>,
|
||||
notify_reporter: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let mut body = serde_json::json!({"status": "resolved"});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
|
||||
@@ -122,6 +122,12 @@ pub struct AdminUser {
|
||||
pub pending_bulk_message_deletion_at: Option<String>,
|
||||
pub deletion_reason_code: Option<i32>,
|
||||
pub deletion_public_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deletion_audit_log_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deletion_scheduled_by: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deletion_scheduled_at: Option<String>,
|
||||
pub last_active_at: Option<String>,
|
||||
pub last_active_ip: Option<String>,
|
||||
pub last_active_ip_reverse: Option<String>,
|
||||
|
||||
@@ -25,9 +25,7 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
@@ -51,28 +49,6 @@ pub struct InstancePolicyResponse {
|
||||
pub services_resolved: InstanceServicesResolved,
|
||||
#[serde(default)]
|
||||
pub services_available: InstanceServicesAvailable,
|
||||
#[serde(default)]
|
||||
pub deferred_phone_gate: DeferredPhoneGateResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeferredPhoneGateResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub window_hours: f64,
|
||||
#[serde(default)]
|
||||
pub member_threshold: i64,
|
||||
}
|
||||
|
||||
impl Default for DeferredPhoneGateResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
window_hours: 6.0,
|
||||
member_threshold: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstancePolicyResponse {
|
||||
@@ -86,7 +62,6 @@ impl Default for InstancePolicyResponse {
|
||||
services: InstanceServicesOverrides::default(),
|
||||
services_resolved: InstanceServicesResolved::default(),
|
||||
services_available: InstanceServicesAvailable::default(),
|
||||
deferred_phone_gate: DeferredPhoneGateResponse::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,8 +100,6 @@ pub struct InstanceIntegrationsResponse {
|
||||
#[serde(default)]
|
||||
pub youtube: InstanceYoutubeIntegrationResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: InstanceCaptchaIntegrationResponse,
|
||||
#[serde(default)]
|
||||
pub email: InstanceEmailIntegrationResponse,
|
||||
#[serde(default)]
|
||||
pub bluesky: InstanceBlueskyIntegrationResponse,
|
||||
@@ -147,21 +120,6 @@ pub struct InstanceYoutubeIntegrationResponse {
|
||||
pub effective_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceCaptchaIntegrationResponse {
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub effective_provider: String,
|
||||
pub hcaptcha_site_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hcaptcha_secret_key_set: bool,
|
||||
pub turnstile_site_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub turnstile_secret_key_set: bool,
|
||||
#[serde(default)]
|
||||
pub effective_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceEmailIntegrationResponse {
|
||||
pub enabled: Option<bool>,
|
||||
@@ -465,10 +423,8 @@ impl VoiceE2eeScope {
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
|
||||
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -540,108 +496,32 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AltchaCaptchaConfigResponse {
|
||||
pub struct CaptchaConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub anonymous_enabled: bool,
|
||||
pub cost: u32,
|
||||
pub max_counter: u32,
|
||||
}
|
||||
|
||||
impl Default for AltchaCaptchaConfigResponse {
|
||||
impl Default for CaptchaConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
enabled: true,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
max_counter: 1_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
pub struct CaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileTimezoneConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ProfileTimezoneConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -760,9 +640,7 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -781,18 +659,6 @@ pub struct InstancePolicyUpdateRequest {
|
||||
pub premium_mode: Option<PremiumMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub services: Option<InstanceServicesUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DeferredPhoneGateUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_hours: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub member_threshold: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -812,8 +678,6 @@ pub struct InstanceIntegrationsUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub youtube: Option<InstanceYoutubeIntegrationUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<InstanceCaptchaIntegrationUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<InstanceEmailIntegrationUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bluesky: Option<InstanceBlueskyIntegrationUpdateRequest>,
|
||||
@@ -831,20 +695,6 @@ pub struct InstanceYoutubeIntegrationUpdateRequest {
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceCaptchaIntegrationUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hcaptcha_site_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hcaptcha_secret_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turnstile_site_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turnstile_secret_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceEmailIntegrationUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1084,34 +934,25 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
fn default_instance_config_sections_match_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.expect("admin schema");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||
.expect("default profile timezone config");
|
||||
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
|
||||
.expect("default captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let altcha_captcha =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
let profile_timezone =
|
||||
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
|
||||
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||
serde_json::from_value(profile_timezone.clone())
|
||||
.expect("generated profile timezone config contract");
|
||||
let generated_captcha: generated_types::CaptchaConfigResponse =
|
||||
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1120,14 +961,8 @@ mod tests {
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_altcha_captcha)
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_profile_timezone)
|
||||
.expect("serializable generated profile timezone config"),
|
||||
profile_timezone
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
@@ -1136,8 +971,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
|
||||
@@ -393,12 +393,14 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
duration_hours: u32,
|
||||
reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
notify_user,
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
@@ -411,10 +413,20 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
pub async fn unban_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserUnbanRequest {
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.unban_admin_user(&snowflake(user_id))
|
||||
.generated_with_reason(private_reason)?
|
||||
.unban_admin_user(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -427,6 +439,7 @@ impl AdminApiClient {
|
||||
reason_code: i32,
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
@@ -436,9 +449,11 @@ impl AdminApiClient {
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
replace_pending_deletion_at: None,
|
||||
};
|
||||
let response = self
|
||||
.generated_with_reason(audit_log_reason)?
|
||||
@@ -449,16 +464,44 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let response = self
|
||||
.generated()
|
||||
.cancel_admin_user_deletion(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
pub async fn cancel_deletion(
|
||||
&self,
|
||||
user_id: &str,
|
||||
expected_pending_deletion_at: &str,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = serde_json::json!({
|
||||
"expected_pending_deletion_at": expected_pending_deletion_at,
|
||||
"notify_user": notify_user,
|
||||
});
|
||||
let resp: UserMutationResponse = self
|
||||
.delete_with_reason(
|
||||
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn annotate_ban(
|
||||
&self,
|
||||
user_id: &str,
|
||||
ban_audit_log_id: &str,
|
||||
note: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = serde_json::json!({
|
||||
"ban_audit_log_id": ban_audit_log_id,
|
||||
"note": note,
|
||||
});
|
||||
self.post_void(
|
||||
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDobUpdateRequest {
|
||||
date_of_birth: dob.to_owned(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::config::normalize_public_endpoint_from_env;
|
||||
use std::env;
|
||||
use fluxer_common::config::{
|
||||
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
|
||||
read_first_env, trim_trailing_slash,
|
||||
};
|
||||
|
||||
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
|
||||
|
||||
@@ -17,12 +19,10 @@ pub struct AdminConfig {
|
||||
pub static_cdn_endpoint: String,
|
||||
pub admin_endpoint: String,
|
||||
pub web_app_endpoint: String,
|
||||
pub kv_url: String,
|
||||
pub oauth_client_id: String,
|
||||
pub oauth_client_secret: String,
|
||||
pub oauth_redirect_uri: String,
|
||||
pub build_version: String,
|
||||
pub release_channel: String,
|
||||
pub self_hosted: bool,
|
||||
pub proxy: ProxyConfig,
|
||||
}
|
||||
@@ -47,8 +47,8 @@ impl AdminConfig {
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
"https://admin.fluxer.app",
|
||||
)));
|
||||
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
|
||||
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
|
||||
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env(
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
&format!("{admin_endpoint}/oauth2_callback"),
|
||||
));
|
||||
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
|
||||
@@ -82,38 +82,22 @@ impl AdminConfig {
|
||||
"FLUXER_APP_ENDPOINT",
|
||||
"https://app.fluxer.app",
|
||||
))),
|
||||
kv_url: read_env("FLUXER_KV_URL", ""),
|
||||
oauth_client_id: read_env(
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
|
||||
DEFAULT_ADMIN_OAUTH_CLIENT_ID,
|
||||
),
|
||||
oauth_client_secret: read_env("FLUXER_ADMIN_OAUTH_CLIENT_SECRET", ""),
|
||||
oauth_redirect_uri,
|
||||
build_version: read_env_preferred(
|
||||
build_version: read_first_env(
|
||||
&["BUILD_VERSION", "FLUXER_BUILD_VERSION"],
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
release_channel: read_env_preferred(
|
||||
&["RELEASE_CHANNEL", "FLUXER_RELEASE_CHANNEL"],
|
||||
"stable",
|
||||
),
|
||||
self_hosted: read_bool_env(&["FLUXER_SELF_HOSTED"], false),
|
||||
self_hosted: read_bool_env("FLUXER_SELF_HOSTED", false),
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: read_bool_env(
|
||||
&["FLUXER_TRUST_CLIENT_IP_HEADER", "TRUST_CLIENT_IP_HEADER"],
|
||||
false,
|
||||
),
|
||||
client_ip_header_name: read_env_preferred(
|
||||
&[
|
||||
"FLUXER_CLIENT_IP_HEADER_NAME",
|
||||
"FLUXER_CLIENT_IP_HEADER",
|
||||
"CLIENT_IP_HEADER_NAME",
|
||||
"CLIENT_IP_HEADER",
|
||||
],
|
||||
"x-forwarded-for",
|
||||
)
|
||||
.trim()
|
||||
.to_ascii_lowercase(),
|
||||
trust_client_ip_header: read_bool_env("FLUXER_TRUST_CLIENT_IP_HEADER", false),
|
||||
client_ip_header_name: read_env("FLUXER_CLIENT_IP_HEADER_NAME", "x-forwarded-for")
|
||||
.trim()
|
||||
.to_ascii_lowercase(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -146,55 +130,21 @@ impl RuntimeEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_base_path(value: &str) -> String {
|
||||
let trimmed = value.trim().trim_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trim_trailing_slash(value: &str) -> String {
|
||||
value.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn read_env(name: &str, fallback: &str) -> String {
|
||||
env::var(name).unwrap_or_else(|_| fallback.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn read_env_preferred(names: &[&str], fallback: &str) -> String {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
|
||||
let Some(value) = names.iter().find_map(|name| env::var(name).ok()) else {
|
||||
return fallback;
|
||||
};
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 11] = [
|
||||
const MANAGED_ENV: [&str; 10] = [
|
||||
"FLUXER_ENV",
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"FLUXER_MASTER_CONFIG",
|
||||
"FLUXER_APP_ENDPOINT",
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
@@ -291,12 +241,10 @@ mod tests {
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
@@ -321,12 +269,10 @@ mod tests {
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
|
||||
@@ -8,9 +8,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with(fluxer_common::config::env_filter("info"))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
|
||||
@@ -215,12 +215,10 @@ mod tests {
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
|
||||
@@ -23,10 +23,6 @@ pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/ip-bans", get(ip_bans).post(ip_bans_post))
|
||||
.route("/email-bans", get(email_bans).post(email_bans_post))
|
||||
.route(
|
||||
"/suspicious-email-domains",
|
||||
get(suspicious_email_domains).post(suspicious_email_domains_post),
|
||||
)
|
||||
.route("/phrase-bans", get(phrase_bans).post(phrase_bans_post))
|
||||
.route("/url-bans", get(url_bans).post(url_bans_post))
|
||||
.route(
|
||||
@@ -72,7 +68,6 @@ macro_rules! ban_get {
|
||||
|
||||
ban_get!(ip_bans, "ip-bans");
|
||||
ban_get!(email_bans, "email-bans");
|
||||
ban_get!(suspicious_email_domains, "suspicious-email-domains");
|
||||
ban_get!(phrase_bans, "phrase-bans");
|
||||
ban_get!(url_bans, "url-bans");
|
||||
ban_get!(file_sha_bans, "file-sha-bans");
|
||||
@@ -141,7 +136,6 @@ macro_rules! ban_post {
|
||||
|
||||
ban_post!(ip_bans_post, "ip-bans");
|
||||
ban_post!(email_bans_post, "email-bans");
|
||||
ban_post!(suspicious_email_domains_post, "suspicious-email-domains");
|
||||
ban_post!(phrase_bans_post, "phrase-bans");
|
||||
ban_post!(url_bans_post, "url-bans");
|
||||
ban_post!(file_sha_bans_post, "file-sha-bans");
|
||||
|
||||
@@ -116,11 +116,6 @@ async fn execute_single_ban(
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||
"suspicious-email-domains" => {
|
||||
client
|
||||
.add_suspicious_email_domain(value, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
|
||||
@@ -143,11 +138,6 @@ async fn execute_single_unban(
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.unban_email(value, audit_log_reason).await,
|
||||
"suspicious-email-domains" => {
|
||||
client
|
||||
.remove_suspicious_email_domain(value, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.unban_url(value, audit_log_reason).await,
|
||||
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
|
||||
@@ -169,7 +159,6 @@ async fn execute_check(
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.check_ip_ban(value).await,
|
||||
"email-bans" => client.check_email_ban(value).await,
|
||||
"suspicious-email-domains" => client.check_suspicious_email_domain(value).await,
|
||||
"phrase-bans" => client.check_phrase_ban(value).await,
|
||||
"url-bans" => client.check_url_ban(value).await,
|
||||
"file-sha-bans" => client.check_file_sha_ban(value).await,
|
||||
|
||||
@@ -261,12 +261,14 @@ pub(crate) async fn bulk_actions_post(
|
||||
);
|
||||
};
|
||||
let public_reason = form.clean("public_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
client
|
||||
.bulk_schedule_user_deletion(
|
||||
&user_ids,
|
||||
reason_code.unwrap_or(2),
|
||||
days.unwrap_or(14),
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
audit_log_reason.as_deref(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -52,6 +52,10 @@ struct ResolveForm {
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter_present: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -227,8 +231,10 @@ async fn report_resolve(
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
|
||||
let notify_reporter =
|
||||
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
|
||||
let result = client
|
||||
.resolve_report(&report_id, public_comment.as_deref(), None)
|
||||
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
|
||||
@@ -4,23 +4,21 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
@@ -222,11 +220,7 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
"update_captcha" => match build_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -615,91 +609,29 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_altcha_captcha_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("altcha_captcha_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
|
||||
captcha: Some(CaptchaConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("captcha_enabled")),
|
||||
cost: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_cost",
|
||||
"captcha_cost",
|
||||
"Cost",
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.end(),
|
||||
*CAPTCHA_COST_RANGE.start(),
|
||||
*CAPTCHA_COST_RANGE.end(),
|
||||
)?,
|
||||
max_counter: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_max_counter",
|
||||
"captcha_max_counter",
|
||||
"Maximum counter",
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
|
||||
*CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*CAPTCHA_MAX_COUNTER_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -808,7 +740,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
_ => None,
|
||||
};
|
||||
let services = build_services_update(form);
|
||||
let deferred_phone_gate = build_deferred_phone_gate_update(form);
|
||||
InstanceConfigUpdateRequest {
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
single_community_enabled: None,
|
||||
@@ -816,36 +747,11 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled,
|
||||
premium_mode,
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_deferred_phone_gate_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Option<DeferredPhoneGateUpdateRequest> {
|
||||
let enabled = form
|
||||
.first("policy_deferred_phone_gate_enabled")
|
||||
.map(|value| value == "true");
|
||||
let window_hours = form
|
||||
.first("policy_deferred_phone_gate_window_hours")
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.filter(|value| *value > 0.0);
|
||||
let member_threshold = form
|
||||
.first("policy_deferred_phone_gate_member_threshold")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0);
|
||||
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(DeferredPhoneGateUpdateRequest {
|
||||
enabled,
|
||||
window_hours,
|
||||
member_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
|
||||
let parse_tristate = |key: &str| match form.first(key) {
|
||||
Some("inherit") => Some(None),
|
||||
@@ -889,13 +795,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
|
||||
api_key: clean("integration_youtube_api_key"),
|
||||
}),
|
||||
captcha: Some(InstanceCaptchaIntegrationUpdateRequest {
|
||||
provider: clean("integration_captcha_provider"),
|
||||
hcaptcha_site_key: clean("integration_hcaptcha_site_key"),
|
||||
hcaptcha_secret_key: clean("integration_hcaptcha_secret_key"),
|
||||
turnstile_site_key: clean("integration_turnstile_site_key"),
|
||||
turnstile_secret_key: clean("integration_turnstile_secret_key"),
|
||||
}),
|
||||
email: Some(InstanceEmailIntegrationUpdateRequest {
|
||||
enabled: Some(form.bool_value("integration_email_enabled")),
|
||||
provider: Some("smtp".to_owned()),
|
||||
@@ -980,7 +879,6 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled: None,
|
||||
premium_mode: None,
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -1369,7 +1267,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
@@ -1394,6 +1292,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1475,145 +1381,65 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
|
||||
fn build_captcha_update_reads_the_switch_and_difficulty_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
|
||||
b"captcha_enabled=true&captcha_cost=%202000%20&captcha_max_counter=400",
|
||||
);
|
||||
let update = build_altcha_captcha_update(&form)
|
||||
let update = build_captcha_update(&form)
|
||||
.expect("valid form")
|
||||
.altcha_captcha
|
||||
.expect("altcha captcha update");
|
||||
.captcha
|
||||
.expect("captcha update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
assert_eq!(update.anonymous_enabled, Some(true));
|
||||
assert_eq!(update.cost, Some(2000));
|
||||
assert_eq!(update.max_counter, Some(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
fn build_captcha_update_turns_the_check_off_when_the_box_is_unchecked() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_altcha_captcha_update(&form).expect("valid form");
|
||||
let request = build_captcha_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"altcha_captcha": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_enabled": false,
|
||||
}})
|
||||
serde_json::json!({"captcha": {"enabled": false}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"altcha_captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 100000",
|
||||
"captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_max_counter=1000001",
|
||||
"Maximum counter must be a whole number between 100 and 1000000",
|
||||
"captcha_cost=20001",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"captcha_max_counter=99",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_max_counter=20001",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_altcha_captcha_update(&form).expect_err("invalid field"),
|
||||
build_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_reads_the_rollout_fields() {
|
||||
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
|
||||
);
|
||||
let update = build_profile_timezone_update(&form)
|
||||
.expect("valid form")
|
||||
.profile_timezone
|
||||
.expect("profile timezone update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_profile_timezone_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"profile_timezone": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
(
|
||||
"domain_migration",
|
||||
build_domain_migration_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
let form = MultiValueForm::parse(
|
||||
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
build(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
|
||||
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_profile_timezone_update(&form).expect_err("invalid field"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
build_domain_migration_update(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
admin_flags, api::client::AdminApiClient, middleware::flash::FlashData,
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
middleware::flash::FlashData,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
@@ -242,12 +244,14 @@ pub async fn dispatch(
|
||||
};
|
||||
let reason = get("reason");
|
||||
let private = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.temp_ban_user(
|
||||
user_id,
|
||||
duration.unwrap_or(24),
|
||||
reason.as_deref(),
|
||||
notify_user,
|
||||
private.as_deref(),
|
||||
)
|
||||
.await,
|
||||
@@ -255,11 +259,23 @@ pub async fn dispatch(
|
||||
"Failed to temporarily ban user",
|
||||
)
|
||||
}
|
||||
"unban" => DispatchOutcome::from_result(
|
||||
client.unban_user(user_id).await,
|
||||
"User unbanned successfully",
|
||||
"Failed to unban user",
|
||||
),
|
||||
"unban" => {
|
||||
let public_reason = get("public_reason");
|
||||
let private_reason = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.unban_user(
|
||||
user_id,
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User unbanned successfully",
|
||||
"Failed to unban user",
|
||||
)
|
||||
}
|
||||
"ban_ip" => {
|
||||
let Some(ip) = get("ip") else {
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
@@ -289,6 +305,7 @@ pub async fn dispatch(
|
||||
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
|
||||
return DispatchOutcome::error("Invalid deletion delay");
|
||||
};
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.schedule_deletion(
|
||||
@@ -296,6 +313,7 @@ pub async fn dispatch(
|
||||
reason_code.unwrap_or(0),
|
||||
public_reason.as_deref(),
|
||||
days.unwrap_or(60),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
@@ -303,11 +321,53 @@ pub async fn dispatch(
|
||||
"Failed to schedule user deletion",
|
||||
)
|
||||
}
|
||||
"cancel_deletion" => DispatchOutcome::from_result(
|
||||
client.cancel_deletion(user_id).await,
|
||||
"User deletion cancelled successfully",
|
||||
"Failed to cancel user deletion",
|
||||
),
|
||||
"cancel_deletion" => {
|
||||
let Some(expected) = get("expected_pending_deletion_at") else {
|
||||
return DispatchOutcome::error(
|
||||
"The pending deletion is missing from the form. Reload and review.",
|
||||
);
|
||||
};
|
||||
if !form.bool_value("confirm") {
|
||||
return DispatchOutcome::error(
|
||||
"Confirm whose deletion you are cancelling before submitting",
|
||||
);
|
||||
}
|
||||
let Some(private_reason) = get("private_reason") else {
|
||||
return DispatchOutcome::error("A private reason is required to cancel a deletion");
|
||||
};
|
||||
let notify_user = form.bool_value("notify_user");
|
||||
match client
|
||||
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The pending deletion changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
|
||||
DispatchOutcome::error("Failed to cancel user deletion")
|
||||
}
|
||||
}
|
||||
}
|
||||
"annotate_ban" => {
|
||||
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
|
||||
return DispatchOutcome::error("The ban audit log entry is missing from the form");
|
||||
};
|
||||
let Some(note) = get("note") else {
|
||||
return DispatchOutcome::error("Note is required");
|
||||
};
|
||||
match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await {
|
||||
Ok(()) => DispatchOutcome::success("Note added to the ban"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The ban changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
|
||||
DispatchOutcome::error("Failed to add the note to the ban")
|
||||
}
|
||||
}
|
||||
}
|
||||
"change_dob" => {
|
||||
let Some(dob) = get("date_of_birth") else {
|
||||
return DispatchOutcome::error("Date of birth is required");
|
||||
|
||||
@@ -111,11 +111,47 @@ pub async fn render(
|
||||
query.delete_all_messages_channel_count.unwrap_or(0),
|
||||
query.delete_all_messages_message_count.unwrap_or(0),
|
||||
));
|
||||
let deletion_scheduler = match u.deletion_scheduled_by.as_deref() {
|
||||
Some(scheduler_id) if u.pending_deletion_at.is_some() && scheduler_id != u.id => {
|
||||
client
|
||||
.get_user_by_id(scheduler_id)
|
||||
.await
|
||||
.log_error("load deletion scheduler")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ban_logs = if u.temp_banned_until.is_some()
|
||||
&& acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW)
|
||||
{
|
||||
client
|
||||
.search_audit_logs(&SearchAuditLogsParams {
|
||||
query: None,
|
||||
admin_user_id: None,
|
||||
target_id: Some(user_id.to_owned()),
|
||||
target_type: Some("user".to_owned()),
|
||||
access: Some("write".to_owned()),
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.log_error("load ban audit logs")
|
||||
.map(|response| response.logs)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let context = tabs::moderation::ModerationContext {
|
||||
deletion_scheduler: deletion_scheduler.as_ref(),
|
||||
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
|
||||
};
|
||||
Some(tabs::moderation::moderation_tab(
|
||||
config,
|
||||
&u,
|
||||
csrf_token,
|
||||
admin_acls,
|
||||
&context,
|
||||
query.message_shred_job_id.as_deref(),
|
||||
message_shred_status.as_ref(),
|
||||
delete_all_messages_dry_run,
|
||||
|
||||
@@ -227,6 +227,13 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opt_out_checkbox(name: &str, label: &str) -> Markup {
|
||||
html! {
|
||||
input type="hidden" name={(name) "_present"} value="1";
|
||||
(checkbox(name, "true", label, true, true))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secondary_button_link(label: &str, href: &str) -> Markup {
|
||||
html! {
|
||||
a href=(href) role="button"
|
||||
|
||||
@@ -114,16 +114,6 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
acl::BAN_EMAIL_REMOVE
|
||||
]
|
||||
),
|
||||
item!(
|
||||
"Suspicious Email Domains",
|
||||
"/suspicious-email-domains",
|
||||
"suspicious-email-domains",
|
||||
[
|
||||
acl::SUSPICIOUS_EMAIL_DOMAIN_CHECK,
|
||||
acl::SUSPICIOUS_EMAIL_DOMAIN_ADD,
|
||||
acl::SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
|
||||
]
|
||||
),
|
||||
item!(
|
||||
"Phrase Bans",
|
||||
"/phrase-bans",
|
||||
|
||||
@@ -25,7 +25,9 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||
| "ban_ip"
|
||||
| "ban_email" => BadgeVariant::Danger,
|
||||
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" => BadgeVariant::Success,
|
||||
"update_flags" | "update_features" | "set_acls" | "update_settings" => BadgeVariant::Info,
|
||||
"update_flags" | "update_features" | "set_acls" | "update_settings" | "annotate_ban" => {
|
||||
BadgeVariant::Info
|
||||
}
|
||||
"delete_message" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
|
||||
@@ -45,17 +45,6 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Suspicious Email Domains",
|
||||
route: "/suspicious-email-domains",
|
||||
input_label: "Email Domain",
|
||||
input_name: "domain",
|
||||
input_type: "text",
|
||||
placeholder: "mail.ru",
|
||||
entity_name: "Domain",
|
||||
active_page: "suspicious-email-domains",
|
||||
show_bulk_tools: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
route: "/phrase-bans",
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::{
|
||||
components::{
|
||||
form::{
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
form_field_group, opt_out_checkbox, select_chevron, submit_button, text_input,
|
||||
textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -410,6 +411,9 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
}
|
||||
},
|
||||
))
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Users that already have a pending deletion are skipped and listed as failed with the reason already scheduled. Cancel those from the user page first to schedule them again."
|
||||
}
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||
@@ -423,6 +427,7 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
}))
|
||||
@@ -481,6 +486,13 @@ mod tests {
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_emails_each_user_by_default() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
|
||||
AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -119,7 +118,13 @@ pub fn instance_config_page(
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
(sso_config_section(base, csrf_token, &instance_config.sso))
|
||||
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Bot protection",
|
||||
"A proof-of-work check on sign-up, login, password reset and a few other abuse-prone actions.",
|
||||
html! {
|
||||
(captcha_section(base, csrf_token, &instance_config.captcha))
|
||||
},
|
||||
))
|
||||
@if instance_config.self_hosted {
|
||||
@@ -176,8 +181,6 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -334,57 +337,6 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
|
||||
}
|
||||
}
|
||||
|
||||
fn deferred_phone_gate_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
) -> Markup {
|
||||
let gate = &policy.deferred_phone_gate;
|
||||
let status = if gate.enabled {
|
||||
("Enabled", BadgeVariant::Success)
|
||||
} else {
|
||||
("Disabled", BadgeVariant::Default)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" }
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"When enabled, a phone requirement raised at registration is held back and only \
|
||||
applied if the account joins a discoverable community, or one above the member \
|
||||
threshold, within the window. Accounts that wait out the window are not challenged. \
|
||||
Inbound-SMS requirements are never deferred."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[
|
||||
("true", "Enabled"),
|
||||
("false", "Disabled"),
|
||||
], if gate.enabled { "true" } else { "false" }))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_window_hours",
|
||||
"Window (hours)",
|
||||
&gate.window_hours.to_string(),
|
||||
"6",
|
||||
))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_member_threshold",
|
||||
"Member threshold",
|
||||
&gate.member_threshold.to_string(),
|
||||
"50",
|
||||
))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save deferred phone verification"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -519,11 +471,6 @@ fn integrations_config_section(
|
||||
csrf_token: &str,
|
||||
integrations: &InstanceIntegrationsResponse,
|
||||
) -> Markup {
|
||||
let captcha_provider = integrations
|
||||
.captcha
|
||||
.provider
|
||||
.as_deref()
|
||||
.unwrap_or(integrations.captcha.effective_provider.as_str());
|
||||
let smtp_port = integrations
|
||||
.email
|
||||
.smtp
|
||||
@@ -555,35 +502,6 @@ fn integrations_config_section(
|
||||
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Bot protection" }
|
||||
(secret_badge("hCaptcha secret", integrations.captcha.hcaptcha_secret_key_set))
|
||||
(secret_badge("Turnstile secret", integrations.captcha.turnstile_secret_key_set))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
|
||||
(select_input("integration_captcha_provider", "Provider", &[
|
||||
("none", "Disabled"),
|
||||
("hcaptcha", "hCaptcha"),
|
||||
("turnstile", "Cloudflare Turnstile"),
|
||||
], captcha_provider))
|
||||
(text_input(
|
||||
"integration_hcaptcha_site_key",
|
||||
"hCaptcha site key",
|
||||
integrations.captcha.hcaptcha_site_key.as_deref().unwrap_or(""),
|
||||
"",
|
||||
))
|
||||
(password_input("integration_hcaptcha_secret_key", "hCaptcha secret key", Some("Leave blank to keep the current secret.")))
|
||||
(text_input(
|
||||
"integration_turnstile_site_key",
|
||||
"Turnstile site key",
|
||||
integrations.captcha.turnstile_site_key.as_deref().unwrap_or(""),
|
||||
"",
|
||||
))
|
||||
(password_input("integration_turnstile_secret_key", "Turnstile secret key", Some("Leave blank to keep the current secret.")))
|
||||
}
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
|
||||
@@ -1257,308 +1175,67 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn altcha_captcha_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
altcha_captcha: &AltchaCaptchaConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if altcha_captcha.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
|
||||
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"ALTCHA Captcha",
|
||||
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
|
||||
selected requesters. The API issues and verifies every challenge itself, so no third \
|
||||
party is involved. Requests only need a captcha where one is already required, so this \
|
||||
does nothing while captcha is off for the instance.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (altcha_captcha.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"altcha_captcha_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to the selected requesters",
|
||||
altcha_captcha.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every \
|
||||
requester gets the configured provider and ALTCHA answers are rejected."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
|
||||
(checkbox(
|
||||
"altcha_captcha_anonymous_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to logged-out requests",
|
||||
altcha_captcha.anonymous_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Covers registration, login and password reset. These requests have no \
|
||||
account to bucket, so this switch applies to all of them at once."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&altcha_captcha.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"altcha_captcha_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&altcha_captcha.rollout_salt,
|
||||
ALTCHA_CAPTCHA_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get ALTCHA \
|
||||
regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"altcha_captcha_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
altcha_captcha.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&altcha_captcha.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
|
||||
(number_field(
|
||||
"altcha_captcha_cost",
|
||||
"Cost (PBKDF2 iterations per attempt)",
|
||||
&altcha_captcha.cost.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
|
||||
"1",
|
||||
Some("The API spends one attempt at this cost to issue each challenge."),
|
||||
))
|
||||
(number_field(
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
&altcha_captcha.max_counter.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||
"1",
|
||||
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
|
||||
))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ALTCHA Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
|
||||
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
|
||||
}
|
||||
|
||||
fn profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse) -> Markup {
|
||||
let status = if captcha.enabled {
|
||||
("On", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
("Off", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
let estimate = estimate_low_end_solve_seconds(captcha.cost, captcha.max_counter);
|
||||
section_card_with_description(
|
||||
"Profile Timezone",
|
||||
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||
stays hidden from everyone while its owner is outside the rollout.",
|
||||
"Proof-of-work check",
|
||||
"Clients solve it in the background. The API issues and verifies every challenge itself, \
|
||||
and no third party is involved.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (profile_timezone.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&profile_timezone.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"profile_timezone_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&profile_timezone.rollout_salt,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get profile \
|
||||
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=update_captcha"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"captcha_enabled",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.include_premium_users,
|
||||
"Require a proof-of-work check",
|
||||
captcha.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
"On by default. Turning it off removes the check from every request."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
(number_field(
|
||||
"captcha_cost",
|
||||
"Cost (PBKDF2 iterations per try)",
|
||||
&captcha.cost.to_string(),
|
||||
Some(*CAPTCHA_COST_RANGE.start()),
|
||||
Some(*CAPTCHA_COST_RANGE.end()),
|
||||
"1",
|
||||
Some("Default 5000."),
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
(number_field(
|
||||
"captcha_max_counter",
|
||||
"Maximum counter",
|
||||
&captcha.max_counter.to_string(),
|
||||
Some(*CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||
Some(*CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||
"1",
|
||||
Some("Default 1000. Solve time grows with cost times this value."),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
p class="text-sm text-neutral-700" {
|
||||
(format!(
|
||||
"Average solve: about {estimate:.1} s on a low-end Android phone, \
|
||||
well under a second in desktop browsers."
|
||||
))
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2180,6 +1857,23 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn captcha_section_posts_the_switch_and_difficulty_fields() {
|
||||
let markup =
|
||||
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
|
||||
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
|
||||
assert!(markup.contains(r#"name="captcha_enabled""#));
|
||||
assert!(markup.contains(r#"name="captcha_cost""#));
|
||||
assert!(markup.contains(r#"name="captcha_max_counter""#));
|
||||
assert!(markup.contains("about 3.6 s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
|
||||
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
|
||||
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
|
||||
@@ -31,7 +31,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
|
||||
div class="flex flex-col gap-2" {
|
||||
label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" }
|
||||
input type="text" id="task_type" name="task_type"
|
||||
value=(p.task_type_filter) placeholder="syncDisposableEmailDomains"
|
||||
value=(p.task_type_filter) placeholder="syncUrlBlocklists"
|
||||
class=(FORM_INPUT_CLASS);
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
||||
form::csrf_input,
|
||||
form::{csrf_input, opt_out_checkbox},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
@@ -376,16 +376,7 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
|
||||
(section_card(Some("Actions"), None, None, html! {
|
||||
div class="flex flex-col gap-3" {
|
||||
@if report.status == 0 {
|
||||
form method="post"
|
||||
action={(base) "/reports/" (&report.report_id) "/resolve"} {
|
||||
(csrf_input(csrf_token))
|
||||
button type="submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 \
|
||||
font-medium rounded-lg bg-neutral-900 text-white \
|
||||
px-4 py-2 text-sm" {
|
||||
"Resolve Report"
|
||||
}
|
||||
}
|
||||
(resolve_report_form(base, &report.report_id, csrf_token))
|
||||
}
|
||||
@if report.report_type == 0 || report.report_type == 1 {
|
||||
@if let Some(ref reported_id) = report.reported_user_id {
|
||||
@@ -410,6 +401,29 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
form method="post" action={(base) "/reports/" (report_id) "/resolve"} class="flex flex-col gap-3" {
|
||||
(csrf_input(csrf_token))
|
||||
label for="resolution" class="block text-sm font-medium text-neutral-700" {
|
||||
"Public comment to the reporter (optional)"
|
||||
}
|
||||
textarea id="resolution" name="resolution" rows="3" maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {}
|
||||
(opt_out_checkbox("notify_reporter", "Notify the reporter by DM and email"))
|
||||
button type="submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 \
|
||||
font-medium rounded-lg bg-neutral-900 text-white \
|
||||
px-4 py-2 text-sm" {
|
||||
"Resolve Report"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nav_link(href: &str, label: &str) -> Markup {
|
||||
html! {
|
||||
a href=(href)
|
||||
@@ -532,3 +546,18 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_form_offers_a_public_comment_and_notifies_the_reporter_by_default() {
|
||||
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
|
||||
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
|
||||
assert!(markup.contains(r#"name="resolution""#));
|
||||
assert!(markup.contains(r#"maxlength="512""#));
|
||||
assert!(markup.contains(r#"name="notify_reporter" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{ApiError, ApiResult},
|
||||
types::{AdminUser, MessageShredStatusResponse},
|
||||
types::{AdminUser, AuditLogEntry, MessageShredStatusResponse},
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, opt_out_checkbox, submit_button,
|
||||
},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -51,11 +54,60 @@ const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("22", "Impersonation or fake identity"),
|
||||
];
|
||||
|
||||
pub struct CurrentBan<'a> {
|
||||
pub entry: &'a AuditLogEntry,
|
||||
pub notes: Vec<&'a AuditLogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ModerationContext<'a> {
|
||||
pub deletion_scheduler: Option<&'a AdminUser>,
|
||||
pub current_ban: Option<CurrentBan<'a>>,
|
||||
}
|
||||
|
||||
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
|
||||
let banned_until = user.temp_banned_until.as_deref()?;
|
||||
let entry = logs.iter().find(|log| {
|
||||
log.action == "temp_ban"
|
||||
&& log.target_id == user.id
|
||||
&& log.metadata.get("banned_until").map(String::as_str) == Some(banned_until)
|
||||
})?;
|
||||
let mut notes: Vec<&AuditLogEntry> = logs
|
||||
.iter()
|
||||
.filter(|log| {
|
||||
log.action == "annotate_ban"
|
||||
&& log.metadata.get("ban_audit_log_id") == Some(&entry.log_id)
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Some(CurrentBan { entry, notes })
|
||||
}
|
||||
|
||||
fn deletion_reason_label(code: i32) -> String {
|
||||
let value = code.to_string();
|
||||
DELETION_REASONS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == value)
|
||||
.map_or_else(
|
||||
|| format!("Reason {code}"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_name(entry: &AuditLogEntry) -> String {
|
||||
entry.admin_user.as_ref().map_or_else(
|
||||
|| entry.admin_user_id.clone(),
|
||||
|admin| admin.username.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn moderation_tab(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
context: &ModerationContext<'_>,
|
||||
message_shred_job_id: Option<&str>,
|
||||
message_shred_status: Option<&ApiResult<MessageShredStatusResponse>>,
|
||||
delete_all_messages_dry_run: Option<(u64, u64)>,
|
||||
@@ -66,8 +118,8 @@ pub fn moderation_tab(
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
|
||||
(ban_actions_card(base, user, csrf_token))
|
||||
(deletion_card(base, user, csrf_token))
|
||||
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
|
||||
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
|
||||
}
|
||||
@if can_delete_all_messages {
|
||||
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
|
||||
@@ -79,16 +131,39 @@ pub fn moderation_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
fn ban_actions_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Ban Actions", html! {
|
||||
@if user.temp_banned_until.is_some() {
|
||||
(current_ban_details(base, user, csrf_token, current_ban))
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Unban User"))
|
||||
}))
|
||||
div class="space-y-3" {
|
||||
(form_label("Public Reason (optional, shown to the user)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public unban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional, audit log)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private unban reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Unban User"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
@@ -107,7 +182,7 @@ fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="reason"
|
||||
placeholder="Enter public ban reason..."
|
||||
placeholder="Enter public ban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
@@ -119,6 +194,7 @@ fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Ban/Suspend User"))
|
||||
}))
|
||||
@@ -129,16 +205,160 @@ fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
fn current_ban_details(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
) -> Markup {
|
||||
let Some(ban) = current_ban else {
|
||||
return html! {
|
||||
p class="mb-4 text-sm text-neutral-500" {
|
||||
"The audit log entry of this ban could not be loaded, so notes cannot be added here."
|
||||
}
|
||||
};
|
||||
};
|
||||
html! {
|
||||
div class="mb-4 space-y-3" {
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Banned by: " }
|
||||
dd class="inline" {
|
||||
a href={(base) "/users/" (ban.entry.admin_user_id)} class="underline" {
|
||||
(admin_name(ban.entry))
|
||||
}
|
||||
" on " (format_admin_timestamp(&ban.entry.created_at))
|
||||
}
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Reason: " }
|
||||
dd class="inline" { (ban.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
|
||||
}
|
||||
}
|
||||
@if !ban.notes.is_empty() {
|
||||
ul class="space-y-1 text-sm text-neutral-700" {
|
||||
@for note in &ban.notes {
|
||||
li {
|
||||
span class="font-medium" { (admin_name(note)) }
|
||||
" (" (format_admin_timestamp(¬e.created_at)) "): "
|
||||
(note.audit_log_reason.as_deref().unwrap_or(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id);
|
||||
div class="space-y-3" {
|
||||
(form_label("Add a note to this ban"))
|
||||
textarea name="note" rows="2" required maxlength="512"
|
||||
placeholder="Appended to the ban. The original reason is kept."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {}
|
||||
(form_actions(html! {
|
||||
(submit_button("Add Note"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_deletion_summary(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
scheduler: Option<&AdminUser>,
|
||||
) -> (String, Markup) {
|
||||
let scheduler_name = match (user.deletion_scheduled_by.as_deref(), scheduler) {
|
||||
(None, _) => "an unrecorded source".to_owned(),
|
||||
(Some(id), _) if id == user.id => "the user".to_owned(),
|
||||
(Some(_), Some(scheduler)) => scheduler.username.clone(),
|
||||
(Some(id), None) => id.to_owned(),
|
||||
};
|
||||
let reason = user
|
||||
.deletion_reason_code
|
||||
.map_or_else(|| "no reason code".to_owned(), deletion_reason_label);
|
||||
let due = user
|
||||
.pending_deletion_at
|
||||
.as_deref()
|
||||
.map(format_admin_timestamp)
|
||||
.unwrap_or_default();
|
||||
let markup = html! {
|
||||
dl class="mb-4 space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Scheduled by: " }
|
||||
dd class="inline" {
|
||||
@match user.deletion_scheduled_by.as_deref() {
|
||||
Some(id) => {
|
||||
a href={(base) "/users/" (id)} class="underline" { (scheduler_name) }
|
||||
}
|
||||
None => { (scheduler_name) }
|
||||
}
|
||||
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
|
||||
" on " (format_admin_timestamp(at))
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Due: " }
|
||||
dd class="inline" { (due) }
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Reason: " }
|
||||
dd class="inline" { (reason) }
|
||||
}
|
||||
@if let Some(public_reason) = &user.deletion_public_reason {
|
||||
div {
|
||||
dt class="inline font-medium" { "Public reason: " }
|
||||
dd class="inline" { (public_reason) }
|
||||
}
|
||||
}
|
||||
@if let Some(private_reason) = &user.deletion_audit_log_reason {
|
||||
div {
|
||||
dt class="inline font-medium" { "Private reason: " }
|
||||
dd class="inline" { (private_reason) }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
format!("Cancel {scheduler_name}'s deletion ({reason}, due {due})"),
|
||||
markup,
|
||||
)
|
||||
}
|
||||
|
||||
fn deletion_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
scheduler: Option<&AdminUser>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Account Deletion", html! {
|
||||
@if user.pending_deletion_at.is_some() {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
@let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
(summary)
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Cancel Deletion"))
|
||||
}))
|
||||
input type="hidden" name="expected_pending_deletion_at" value=(pending);
|
||||
div class="space-y-3" {
|
||||
(form_label("Private Reason"))
|
||||
input type="text" name="private_reason" required
|
||||
placeholder="Why this deletion is being cancelled (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Deletion"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
@@ -153,18 +373,19 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Reason"))
|
||||
select name="reason_code"
|
||||
select name="reason_code" required
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public reason..."
|
||||
placeholder="Enter public reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
@@ -176,6 +397,7 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Schedule Deletion"))
|
||||
}))
|
||||
@@ -513,3 +735,139 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn user(extra: Value) -> AdminUser {
|
||||
let mut value =
|
||||
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
|
||||
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
|
||||
target.extend(fields.clone());
|
||||
}
|
||||
serde_json::from_value(value).expect("valid admin user")
|
||||
}
|
||||
|
||||
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1400000000000000001",
|
||||
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
|
||||
"action": action,
|
||||
"target_id": "1500000000000000001",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": reason,
|
||||
"metadata": metadata,
|
||||
"created_at": "2026-09-01T10:00:00.000Z"
|
||||
}))
|
||||
.expect("valid audit log entry")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
|
||||
let target = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3,
|
||||
"deletion_public_reason": "Spam",
|
||||
"deletion_audit_log_reason": "Report batch 12",
|
||||
"deletion_scheduled_by": "1400000000000000001",
|
||||
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
|
||||
}));
|
||||
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
|
||||
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
|
||||
assert!(markup.contains("lilith"));
|
||||
assert!(markup.contains("Report batch 12"));
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
|
||||
)
|
||||
);
|
||||
assert!(markup.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
|
||||
assert!(markup.contains(r#"name="private_reason" required"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_makes_the_reason_an_explicit_choice() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
|
||||
assert!(!markup.contains(r#"<option value="1" selected>"#));
|
||||
assert!(!markup.contains("replace_pending_deletion_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_emails_the_user_by_default() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_ban_form_emails_the_user_by_default() {
|
||||
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unban_form_separates_the_public_and_private_reasons() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
|
||||
assert!(markup.contains("?action=unban&tab=moderation"));
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(markup.contains(
|
||||
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
|
||||
));
|
||||
assert!(markup.contains(r#"name="private_reason""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let logs = vec![
|
||||
entry(
|
||||
"3",
|
||||
"annotate_ban",
|
||||
"Also sent links",
|
||||
json!({"ban_audit_log_id": "2"}),
|
||||
),
|
||||
entry(
|
||||
"2",
|
||||
"temp_ban",
|
||||
"Regel § 3",
|
||||
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"1",
|
||||
"temp_ban",
|
||||
"Older ban",
|
||||
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"4",
|
||||
"annotate_ban",
|
||||
"Old note",
|
||||
json!({"ban_audit_log_id": "1"}),
|
||||
),
|
||||
];
|
||||
let ban = find_current_ban(&target, &logs).expect("current ban");
|
||||
assert_eq!(ban.entry.log_id, "2");
|
||||
assert_eq!(
|
||||
ban.notes
|
||||
.iter()
|
||||
.map(|note| note.log_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["3"]
|
||||
);
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
|
||||
assert!(markup.contains("Regel § 3"));
|
||||
assert!(markup.contains("Also sent links"));
|
||||
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
|
||||
assert!(markup.contains("?action=annotate_ban&tab=moderation"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ use crate::{
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::{card_with_header, detail_row},
|
||||
},
|
||||
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
|
||||
utils::{
|
||||
bigint::format_discriminator,
|
||||
timestamps::{format_admin_timestamp, snowflake_creation_date},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -70,6 +73,22 @@ fn render_overview_tab(
|
||||
@if let Some(reason) = &user.deletion_public_reason {
|
||||
div class="mt-1" { "Public reason: " (reason) }
|
||||
}
|
||||
@if let Some(reason) = &user.deletion_audit_log_reason {
|
||||
div class="mt-1" { "Private reason: " (reason) }
|
||||
}
|
||||
div class="mt-1" {
|
||||
"Scheduled by "
|
||||
@match user.deletion_scheduled_by.as_deref() {
|
||||
Some(id) if id == user.id => { "the user" }
|
||||
Some(id) => {
|
||||
a href={(config.base_path) "/users/" (id)} class="underline" { (id) }
|
||||
}
|
||||
None => { "an unrecorded source" }
|
||||
}
|
||||
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
|
||||
" on " (format_admin_timestamp(at))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +312,7 @@ fn flags_card(
|
||||
))
|
||||
@if user.phone_verification_deferred {
|
||||
p class="text-sm text-amber-700 dark:text-amber-400" {
|
||||
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
|
||||
"Phone verification is deferred: the requirement above is stored but not enforced."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ impl MultiValueForm {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn opt_out_value(&self, key: &str) -> bool {
|
||||
!self.contains_key(&format!("{key}_present")) || self.bool_value(key)
|
||||
}
|
||||
|
||||
pub fn list_values(&self, key: &str) -> Vec<String> {
|
||||
self.fields
|
||||
.get(key)
|
||||
|
||||
@@ -37,6 +37,9 @@ fn deserialize_admin_users_me_response() {
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": ["super_admin"],
|
||||
"traits": ["beta_tester"],
|
||||
"has_totp": true,
|
||||
@@ -81,7 +84,8 @@ fn deserialize_flags_as_string_and_number() {
|
||||
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0, "temp_banned_until": null,
|
||||
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null, "deletion_public_reason": null,
|
||||
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
|
||||
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
|
||||
"last_active_at": null, "last_active_ip": null,
|
||||
"last_active_ip_reverse": null, "last_active_location": null
|
||||
@@ -113,7 +117,8 @@ fn deserialize_discriminator_int_and_string() {
|
||||
"premium_lifetime_sequence": null, "suspicious_activity_flags": 0,
|
||||
"temp_banned_until": null, "pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null, "deletion_reason_code": null,
|
||||
"deletion_public_reason": null, "acls": [], "traits": [],
|
||||
"deletion_public_reason": null, "deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null, "deletion_scheduled_at": null, "acls": [], "traits": [],
|
||||
"has_totp": false, "authenticator_types": [],
|
||||
"last_active_at": null, "last_active_ip": null,
|
||||
"last_active_ip_reverse": null, "last_active_location": null
|
||||
@@ -170,6 +175,9 @@ fn deserialize_search_users_response() {
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
@@ -414,30 +422,11 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"captcha": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 500,
|
||||
"rollout_salt": "altcha-captcha-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000003"],
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"profile_timezone": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "profile-timezone-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"future_profile_timezone_knob": true
|
||||
"max_counter": 1000,
|
||||
"future_captcha_knob": 1
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
@@ -484,25 +473,11 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"youtube_enabled": true,
|
||||
"bluesky_enabled": false
|
||||
},
|
||||
"services_available": {"gif": true, "youtube": true, "bluesky": false},
|
||||
"deferred_phone_gate": {
|
||||
"enabled": false,
|
||||
"window_hours": 24,
|
||||
"member_threshold": 100
|
||||
}
|
||||
"services_available": {"gif": true, "youtube": true, "bluesky": false}
|
||||
},
|
||||
"integrations": {
|
||||
"gif": {"klipy_api_key_set": true, "effective_available": true},
|
||||
"youtube": {"api_key_set": true, "effective_available": true},
|
||||
"captcha": {
|
||||
"provider": "hcaptcha",
|
||||
"effective_provider": "hcaptcha",
|
||||
"hcaptcha_site_key": "site",
|
||||
"hcaptcha_secret_key_set": true,
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key_set": false,
|
||||
"effective_enabled": true
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
@@ -603,16 +578,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.altcha_captcha.enabled);
|
||||
assert_eq!(resp.altcha_captcha.config_version, 3);
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert!(resp.profile_timezone.enabled);
|
||||
assert_eq!(resp.profile_timezone.config_version, 2);
|
||||
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
|
||||
assert!(resp.profile_timezone.include_premium_users);
|
||||
assert!(resp.captcha.enabled);
|
||||
assert_eq!(resp.captcha.max_counter, 1000);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -899,7 +866,8 @@ fn deserialize_user_mutation_response() {
|
||||
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0, "temp_banned_until": null,
|
||||
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null, "deletion_public_reason": null,
|
||||
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
|
||||
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
|
||||
"last_active_at": null, "last_active_ip": null,
|
||||
"last_active_ip_reverse": null, "last_active_location": null
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
@@ -93,12 +95,10 @@ fn production_config(api_endpoint: String) -> AdminConfig {
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: ADMIN_ORIGIN.to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
@@ -273,6 +273,9 @@ fn admin_user() -> Value {
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
@@ -954,6 +956,9 @@ fn user(id: &str, username: &str) -> Value {
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
@@ -1293,12 +1298,10 @@ fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "notification-writes-test-secret";
|
||||
const USER_ID: &str = "1500000000000000001";
|
||||
const REPORT_ID: &str = "1600000000000000001";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CapturedRequest {
|
||||
route: String,
|
||||
audit_log_reason: Option<String>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
type CapturedRequests = Arc<Mutex<Vec<CapturedRequest>>>;
|
||||
|
||||
async fn submit(uri: &str, fields: &str) -> CapturedRequest {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(&app, uri, &format!("_csrf={csrf_token}&{fields}")).await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
let captured = app.captured.lock().expect("captured requests");
|
||||
assert_eq!(captured.len(), 1, "expected one write request");
|
||||
captured[0].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temp_ban_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=temp_ban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"duration=24&reason=Spam¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PUT /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(&uri, "duration=24&reason=Spam¬ify_user_present=1").await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "duration=24&reason=Spam").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unban_sends_the_public_reason_in_the_body_and_the_private_reason_as_a_header() {
|
||||
let uri = format!("/users/{USER_ID}?action=unban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"public_reason=Appeal%20accepted&private_reason=Private%20staff%20note¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("DELETE /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
assert_eq!(checked.body["public_reason"], json!("Appeal accepted"));
|
||||
assert_eq!(
|
||||
checked.audit_log_reason.as_deref(),
|
||||
Some("Private staff note")
|
||||
);
|
||||
assert!(!checked.body.to_string().contains("Private staff note"));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
"private_reason=Private%20staff%20note¬ify_user_present=1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
assert!(!unchecked.body.to_string().contains("Private staff note"));
|
||||
let stale_form = submit(&uri, "").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=schedule_deletion&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
checked.route,
|
||||
format!("PUT /admin/users/{USER_ID}/deletion")
|
||||
);
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "reason_code=3&days_until_deletion=60").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = "/bulk-actions?action=bulk-schedule-user-deletion";
|
||||
let fields = format!("user_ids={USER_ID}&reason_code=3&days_until_deletion=60");
|
||||
let checked = submit(
|
||||
uri,
|
||||
&format!("{fields}¬ify_user_present=1¬ify_user=true"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, "POST /admin/bulk-jobs");
|
||||
assert_eq!(checked.body["task"], json!("schedule_user_deletion"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(uri, &format!("{fields}¬ify_user_present=1")).await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(uri, &fields).await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
let uri = format!("/reports/{REPORT_ID}/resolve");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"resolution=Handled¬ify_reporter_present=1¬ify_reporter=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PATCH /admin/reports/{REPORT_ID}"));
|
||||
assert_eq!(checked.body["public_comment"], json!("Handled"));
|
||||
assert_eq!(checked.body["notify_reporter"], json!(true));
|
||||
let unchecked = submit(&uri, "resolution=Handled¬ify_reporter_present=1").await;
|
||||
assert_eq!(unchecked.body["notify_reporter"], json!(false));
|
||||
let stale_form = submit(&uri, "resolution=Handled").await;
|
||||
assert_eq!(stale_form.body["notify_reporter"], json!(true));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
captured: CapturedRequests,
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let captured: CapturedRequests = Arc::new(Mutex::new(Vec::new()));
|
||||
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
captured,
|
||||
}
|
||||
}
|
||||
|
||||
async fn csrf_token(app: &TestApp) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/voice-regions")
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
.expect("csrf_token cookie")
|
||||
}
|
||||
|
||||
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
||||
)
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(captured: CapturedRequests) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new().fallback(mock_api).with_state(captured),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(
|
||||
State(captured): State<CapturedRequests>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method != Method::GET {
|
||||
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
let audit_log_reason = headers
|
||||
.get("x-audit-log-reason")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned);
|
||||
captured
|
||||
.lock()
|
||||
.expect("captured requests")
|
||||
.push(CapturedRequest {
|
||||
route: format!("{method} {path}"),
|
||||
audit_log_reason,
|
||||
body,
|
||||
});
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/voice/regions") => {
|
||||
Json(json!({ "regions": [region()] })).into_response()
|
||||
}
|
||||
(Method::PUT | Method::DELETE, _) if path.starts_with("/admin/users/") => {
|
||||
Json(json!({ "user": admin_user() })).into_response()
|
||||
}
|
||||
(Method::POST, "/admin/bulk-jobs") => Json(json!({ "job_id": "1" })).into_response(),
|
||||
(Method::PATCH, _) if path.starts_with("/admin/reports/") => Json(json!({
|
||||
"report_id": REPORT_ID,
|
||||
"status": 1,
|
||||
"resolved_at": null,
|
||||
"public_comment": null
|
||||
}))
|
||||
.into_response(),
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> Value {
|
||||
json!({
|
||||
"id": "europe-north",
|
||||
"name": "Northern Europe",
|
||||
"emoji": "flag",
|
||||
"latitude": 59.33,
|
||||
"longitude": 18.06,
|
||||
"is_default": true,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_user() -> Value {
|
||||
json!({
|
||||
"id": "1500000000000000000",
|
||||
"username": "AdminUser",
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"global_name": "AdminUser",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"has_totp": true,
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
|
||||
@@ -55,12 +55,10 @@ fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminCon
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
|
||||
kv_url: "redis://127.0.0.1:6379/0".to_owned(),
|
||||
oauth_client_id: "1234567890123456789".to_owned(),
|
||||
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
|
||||
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
|
||||
build_version: "parity".to_owned(),
|
||||
release_channel: "parity".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
@@ -302,6 +304,9 @@ fn admin_user() -> Value {
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
@@ -321,12 +326,10 @@ fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"@pkgs/nats": "workspace:*",
|
||||
"@pkgs/postgres": "workspace:*",
|
||||
"@pkgs/rate_limit": "workspace:*",
|
||||
"@pkgs/sms": "workspace:*",
|
||||
"@pkgs/virus_scan": "workspace:*",
|
||||
"@pkgs/worker": "workspace:*",
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HcaptchaProvider} from '@pkgs/captcha/src/providers/HcaptchaProvider';
|
||||
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
import type {RecaptchaProviderOptions} from '@pkgs/captcha/src/providers/RecaptchaProvider';
|
||||
import {RecaptchaProvider} from '@pkgs/captcha/src/providers/RecaptchaProvider';
|
||||
import {TestCaptchaProvider} from '@pkgs/captcha/src/providers/TestProvider';
|
||||
import {TurnstileProvider} from '@pkgs/captcha/src/providers/TurnstileProvider';
|
||||
import {UnavailableCaptchaProvider} from '@pkgs/captcha/src/providers/UnavailableCaptchaProvider';
|
||||
|
||||
interface BaseCaptchaProviderFactoryParams {
|
||||
logger?: LoggerInterface;
|
||||
}
|
||||
|
||||
interface CreateUnavailableCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'unavailable';
|
||||
}
|
||||
|
||||
interface CreateTestCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'test';
|
||||
}
|
||||
|
||||
interface CreateHcaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'hcaptcha';
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CreateTurnstileProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'turnstile';
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CreateRecaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'recaptcha';
|
||||
secretKey: string;
|
||||
minimumScore?: number;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
type CreateCaptchaProviderParams =
|
||||
| CreateUnavailableCaptchaProviderParams
|
||||
| CreateTestCaptchaProviderParams
|
||||
| CreateHcaptchaProviderParams
|
||||
| CreateTurnstileProviderParams
|
||||
| CreateRecaptchaProviderParams;
|
||||
|
||||
function buildHttpOptions(
|
||||
params: CreateHcaptchaProviderParams | CreateTurnstileProviderParams | CreateRecaptchaProviderParams,
|
||||
): HttpCaptchaProviderOptions {
|
||||
return {
|
||||
secretKey: params.secretKey,
|
||||
logger: params.logger,
|
||||
timeoutMs: params.timeoutMs,
|
||||
userAgent: params.userAgent,
|
||||
fetchFn: params.fetchFn,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCaptchaProvider(params: CreateCaptchaProviderParams): ICaptchaProvider {
|
||||
if (params.mode === 'test') {
|
||||
return new TestCaptchaProvider();
|
||||
}
|
||||
if (params.mode === 'hcaptcha') {
|
||||
return new HcaptchaProvider(buildHttpOptions(params));
|
||||
}
|
||||
if (params.mode === 'turnstile') {
|
||||
return new TurnstileProvider(buildHttpOptions(params));
|
||||
}
|
||||
if (params.mode === 'recaptcha') {
|
||||
const options: RecaptchaProviderOptions = {
|
||||
...buildHttpOptions(params),
|
||||
minimumScore: params.minimumScore,
|
||||
};
|
||||
return new RecaptchaProvider(options);
|
||||
}
|
||||
return new UnavailableCaptchaProvider();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface VerifyCaptchaParams {
|
||||
token: string;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
verify(params: VerifyCaptchaParams): Promise<boolean>;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
@@ -56,8 +55,7 @@ function decodePayload(token: string): AltchaPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
export class AltchaProvider {
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
@@ -79,7 +77,7 @@ export class AltchaProvider implements ICaptchaProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
async verify({token}: {token: string}): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
export class HcaptchaProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'hcaptcha';
|
||||
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
|
||||
protected readonly providerName = 'hCaptcha';
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
|
||||
const DEFAULT_TIMEOUT = ms('10 seconds');
|
||||
|
||||
export interface HttpCaptchaProviderOptions {
|
||||
secretKey: string;
|
||||
logger?: LoggerInterface;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CaptchaVerifyResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: Array<string>;
|
||||
hostname?: string;
|
||||
challenge_ts?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
const errorCodes = data['error-codes'];
|
||||
return (
|
||||
typeof data.success === 'boolean' &&
|
||||
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
|
||||
(data.hostname === undefined || typeof data.hostname === 'string') &&
|
||||
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
|
||||
(data.score === undefined ||
|
||||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
|
||||
abstract readonly type: CaptchaProviderType;
|
||||
protected readonly secretKey: string;
|
||||
protected readonly logger: LoggerInterface | undefined;
|
||||
protected readonly timeoutMs: number;
|
||||
protected readonly userAgent: string;
|
||||
protected readonly fetchFn: typeof fetch;
|
||||
protected abstract readonly verifyUrl: string;
|
||||
protected abstract readonly providerName: string;
|
||||
|
||||
constructor(options: HttpCaptchaProviderOptions) {
|
||||
this.secretKey = options.secretKey;
|
||||
this.logger = options.logger;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
||||
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
||||
this.fetchFn = options.fetchFn ?? fetch;
|
||||
}
|
||||
|
||||
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('secret', this.secretKey);
|
||||
body.append('response', token);
|
||||
if (remoteIp) {
|
||||
body.append('remoteip', remoteIp);
|
||||
}
|
||||
const response = await this.fetchFn(this.verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': this.userAgent,
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {
|
||||
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
|
||||
});
|
||||
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
|
||||
return false;
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (!isCaptchaVerifyResponse(data)) {
|
||||
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
|
||||
return false;
|
||||
}
|
||||
if (!data.success) {
|
||||
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
|
||||
return false;
|
||||
}
|
||||
return this.validateResponse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
|
||||
} else {
|
||||
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
const DEFAULT_MINIMUM_SCORE = 0.5;
|
||||
|
||||
interface RecaptchaVerifyResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: Array<string>;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
|
||||
minimumScore?: number;
|
||||
}
|
||||
|
||||
export class RecaptchaProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'recaptcha';
|
||||
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
protected readonly providerName = 'reCAPTCHA';
|
||||
private readonly minimumScore: number;
|
||||
|
||||
constructor(options: RecaptchaProviderOptions) {
|
||||
super(options);
|
||||
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
|
||||
}
|
||||
|
||||
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
|
||||
if (data.score !== undefined && data.score < this.minimumScore) {
|
||||
this.logger?.warn(
|
||||
{score: data.score, minimumScore: this.minimumScore},
|
||||
'reCAPTCHA score below minimum threshold',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
|
||||
export class TestCaptchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'test';
|
||||
|
||||
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
export class TurnstileProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'turnstile';
|
||||
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
protected readonly providerName = 'Turnstile';
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
|
||||
export class UnavailableCaptchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'unavailable';
|
||||
|
||||
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ function formatMinorUnitAmount(amountMinor: number, currency: string, locale: st
|
||||
return formatter.format(amountMinor / 10 ** fractionDigits);
|
||||
}
|
||||
|
||||
function optionalReason(reason: string | null): string | null {
|
||||
return reason?.trim() || null;
|
||||
}
|
||||
|
||||
export class EmailService implements IEmailService {
|
||||
private readonly config: EmailConfig;
|
||||
private readonly emailI18n: IEmailI18nService;
|
||||
@@ -83,7 +87,7 @@ export class EmailService implements IEmailService {
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_disabled_suspicious', locale, {
|
||||
username,
|
||||
reason,
|
||||
reason: optionalReason(reason),
|
||||
forgotUrl: `${this.config.appBaseUrl}/forgot`,
|
||||
});
|
||||
}
|
||||
@@ -98,7 +102,7 @@ export class EmailService implements IEmailService {
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_temp_banned', locale, {
|
||||
username,
|
||||
reason,
|
||||
reason: optionalReason(reason),
|
||||
durationHours,
|
||||
bannedUntil,
|
||||
termsUrl: `${this.config.marketingBaseUrl}/terms`,
|
||||
@@ -115,7 +119,7 @@ export class EmailService implements IEmailService {
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_scheduled_deletion', locale, {
|
||||
username,
|
||||
reason,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
termsUrl: `${this.config.marketingBaseUrl}/terms`,
|
||||
guidelinesUrl: `${this.config.marketingBaseUrl}/guidelines`,
|
||||
@@ -131,23 +135,63 @@ export class EmailService implements IEmailService {
|
||||
return this.sendTemplatedEmail(email, 'self_deletion_scheduled', locale, {username, deletionDate});
|
||||
}
|
||||
|
||||
async sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_deletion_scheduled_requested', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_deletion_scheduled_inactivity', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountDeletionCancelledEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_deletion_cancelled', locale, {username});
|
||||
}
|
||||
|
||||
async sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'unban_notification', locale, {username, reason});
|
||||
return this.sendTemplatedEmail(email, 'unban_notification', locale, {username, reason: optionalReason(reason)});
|
||||
}
|
||||
|
||||
async sendScheduledDeletionNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
deletionDate: Date,
|
||||
reason: string,
|
||||
reason: string | null,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'scheduled_deletion_notification', locale, {username, deletionDate, reason});
|
||||
return this.sendTemplatedEmail(email, 'scheduled_deletion_notification', locale, {
|
||||
username,
|
||||
deletionDate,
|
||||
reason: optionalReason(reason),
|
||||
});
|
||||
}
|
||||
|
||||
async sendInactivityWarningEmail(
|
||||
|
||||
@@ -43,6 +43,21 @@ export interface IEmailService {
|
||||
deletionDate: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountDeletionCancelledEmail(email: string, username: string, locale?: string | null): Promise<boolean>;
|
||||
sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
@@ -53,7 +68,7 @@ export interface IEmailService {
|
||||
email: string,
|
||||
username: string,
|
||||
deletionDate: Date,
|
||||
reason: string,
|
||||
reason: string | null,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendInactivityWarningEmail(
|
||||
|
||||
@@ -123,6 +123,43 @@ export class TestEmailService implements ITestEmailService {
|
||||
return this.record(email, 'self_deletion_scheduled', {deletion_date: deletionDate.toISOString()});
|
||||
}
|
||||
|
||||
async sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Requested deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_deletion_scheduled_requested', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Inactivity deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_deletion_scheduled_inactivity', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountDeletionCancelledEmail(email: string, username: string, _locale?: string | null): Promise<boolean> {
|
||||
this.logger.info(`Deletion cancelled email sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'account_deletion_cancelled');
|
||||
}
|
||||
|
||||
async sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
@@ -137,13 +174,16 @@ export class TestEmailService implements ITestEmailService {
|
||||
email: string,
|
||||
username: string,
|
||||
deletionDate: Date,
|
||||
reason: string,
|
||||
reason: string | null,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Scheduled deletion notification sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'scheduled_deletion_notification', {deletion_date: deletionDate.toISOString(), reason});
|
||||
return this.record(email, 'scheduled_deletion_notification', {
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
reason: reason ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
async sendInactivityWarningEmail(
|
||||
|
||||
@@ -14,6 +14,33 @@ const DEFAULT_EMAIL_TEMPLATE_VARIABLES = {
|
||||
appeals_email: '[email protected]',
|
||||
safety_email: '[email protected]',
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
function formatEmailDate(value: unknown, locale: string, style: string | null): string {
|
||||
const options: Intl.DateTimeFormatOptions = {timeZone: 'UTC', day: 'numeric', month: 'short', year: 'numeric'};
|
||||
if (style === 'full') {
|
||||
options.weekday = 'long';
|
||||
options.month = 'long';
|
||||
} else if (style === 'long') {
|
||||
options.month = 'long';
|
||||
} else if (style === 'short') {
|
||||
options.month = 'numeric';
|
||||
}
|
||||
return new Date(value as string | number | Date).toLocaleDateString(locale, options);
|
||||
}
|
||||
|
||||
function formatEmailTime(value: unknown, locale: string, style: string | null): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
};
|
||||
if (style === 'full' || style === 'long') {
|
||||
options.second = 'numeric';
|
||||
}
|
||||
return new Date(value as string | number | Date).toLocaleTimeString(locale, options);
|
||||
}
|
||||
|
||||
const emailI18n = createStaticI18n<EmailTemplateKey, EmailTemplate, Record<string, unknown>>(
|
||||
{
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
@@ -32,6 +59,7 @@ const emailI18n = createStaticI18n<EmailTemplateKey, EmailTemplate, Record<strin
|
||||
validateVariables: (_key, template, variables) =>
|
||||
validateMessageTemplateVariables(template.subject, variables) ??
|
||||
validateMessageTemplateVariables(template.body, variables),
|
||||
messageFormatOptions: {customFormatters: {date: formatEmailDate, time: formatEmailTime}},
|
||||
},
|
||||
(template, variables, mf) => {
|
||||
const compiledSubject = String(mf.compile(template.subject)(variables));
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {extractMessageTemplatePlaceholders} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
|
||||
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {getEmailTemplate, resetEmailI18n} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import {EMAIL_I18N_LOCALE_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nLocales';
|
||||
import {EMAIL_I18N_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const LOCALES = Object.keys(EMAIL_I18N_LOCALE_MESSAGES) as Array<keyof typeof EMAIL_I18N_LOCALE_MESSAGES>;
|
||||
const TEMPLATE_KEYS = Object.keys(EMAIL_I18N_MESSAGES) as Array<EmailTemplateKey>;
|
||||
const DATE = new Date('2026-10-01T23:30:00Z');
|
||||
|
||||
const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
|
||||
account_deletion_cancelled: {username: 'testuser'},
|
||||
account_deletion_scheduled_inactivity: {username: 'testuser', reason: 'Inactive', deletionDate: DATE},
|
||||
account_deletion_scheduled_requested: {username: 'testuser', reason: 'Requested', deletionDate: DATE},
|
||||
account_disabled_suspicious: {username: 'testuser', reason: 'Spam', forgotUrl: 'https://example.com/forgot'},
|
||||
account_scheduled_deletion: {
|
||||
username: 'testuser',
|
||||
reason: 'Spam',
|
||||
deletionDate: DATE,
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
},
|
||||
account_temp_banned: {
|
||||
username: 'testuser',
|
||||
reason: 'Spam',
|
||||
durationHours: 24,
|
||||
bannedUntil: DATE,
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
},
|
||||
donation_confirmation: {amount: '$5.00', currency: 'USD', interval: 'month', manageUrl: 'https://example.com/m'},
|
||||
donation_magic_link: {manageUrl: 'https://example.com/m', expiresAt: DATE},
|
||||
dsa_report_verification: {code: '123456', expiresAt: DATE},
|
||||
email_change_new: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
email_change_original: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
email_change_revert: {username: 'testuser', newEmail: '[email protected]', revertUrl: 'https://example.com/r'},
|
||||
email_verification: {username: 'testuser', verifyUrl: 'https://example.com/verify'},
|
||||
gift_chargeback_notification: {username: 'testuser'},
|
||||
harvest_completed: {
|
||||
username: 'testuser',
|
||||
downloadUrl: 'https://example.com/d',
|
||||
totalMessages: 1200,
|
||||
fileSizeMB: 3.5,
|
||||
expiresAt: DATE,
|
||||
},
|
||||
inactivity_warning: {
|
||||
username: 'testuser',
|
||||
deletionDate: DATE,
|
||||
lastActiveDate: DATE,
|
||||
loginUrl: 'https://example.com/login',
|
||||
},
|
||||
ip_authorization: {
|
||||
username: 'testuser',
|
||||
authUrl: 'https://example.com/a',
|
||||
ipAddress: '192.0.2.1',
|
||||
location: 'Stockholm',
|
||||
},
|
||||
mfa_backup_codes_view: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
password_change_verification: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
password_reset: {username: 'testuser', resetUrl: 'https://example.com/reset'},
|
||||
registration_approved: {username: 'testuser', channelsUrl: 'https://example.com/channels'},
|
||||
report_resolved: {username: 'testuser', reportId: '1', publicComment: 'Thanks', hasComment: 'yes'},
|
||||
scheduled_deletion_notification: {username: 'testuser', deletionDate: DATE, reason: 'Payment fraud'},
|
||||
self_deletion_scheduled: {username: 'testuser', deletionDate: DATE},
|
||||
unban_notification: {username: 'testuser', reason: 'Appeal accepted'},
|
||||
};
|
||||
|
||||
function renderBody<K extends EmailTemplateKey>(key: K, locale: string, variables: EmailTemplateVariables[K]): string {
|
||||
const result = getEmailTemplate(key, locale, variables);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.value.body;
|
||||
}
|
||||
|
||||
describe('EmailI18n locale files', () => {
|
||||
afterEach(() => {
|
||||
@@ -23,4 +96,80 @@ describe('EmailI18n locale files', () => {
|
||||
const localeKeys = Object.keys(EMAIL_I18N_LOCALE_MESSAGES[locale]).sort();
|
||||
expect(localeKeys).toEqual(messagesKeys);
|
||||
});
|
||||
it.each(LOCALES)('%s keeps the source placeholders in every template', (locale) => {
|
||||
const messages: Partial<Record<EmailTemplateKey, {subject: string; body: string}>> =
|
||||
EMAIL_I18N_LOCALE_MESSAGES[locale];
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const source = EMAIL_I18N_MESSAGES[key];
|
||||
const translated = messages[key];
|
||||
expect(translated, key).toBeDefined();
|
||||
if (!translated) continue;
|
||||
expect(extractMessageTemplatePlaceholders(translated.subject), `${key}.subject`).toEqual(
|
||||
extractMessageTemplatePlaceholders(source.subject),
|
||||
);
|
||||
expect(extractMessageTemplatePlaceholders(translated.body), `${key}.body`).toEqual(
|
||||
extractMessageTemplatePlaceholders(source.body),
|
||||
);
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s renders every template with UTC times', (locale) => {
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const result = getEmailTemplate(key, locale, FIXTURE[key]);
|
||||
expect(result.ok, key).toBe(true);
|
||||
if (!result.ok) continue;
|
||||
expect(result.value.body, key).not.toContain('GMT');
|
||||
expect(result.value.body, key).not.toContain('Coordinated Universal Time');
|
||||
}
|
||||
});
|
||||
it('renders dates and times in UTC with a zone label', () => {
|
||||
expect(renderBody('self_deletion_scheduled', 'en-US', {username: 'testuser', deletionDate: DATE})).toContain(
|
||||
'Thursday, October 1, 2026 at 11:30 PM UTC',
|
||||
);
|
||||
});
|
||||
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
|
||||
'%s makes no enforcement claim',
|
||||
(key) => {
|
||||
const body = renderBody(key, 'en-US', {username: 'testuser', reason: 'Some reason', deletionDate: DATE});
|
||||
expect(body).not.toContain('Terms of Service');
|
||||
expect(body.toLowerCase()).not.toContain('appeal');
|
||||
expect(body).toContain('Reason: Some reason');
|
||||
},
|
||||
);
|
||||
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
|
||||
'%s leaves no gap without a reason',
|
||||
(key) => {
|
||||
const body = renderBody(key, 'en-US', {username: 'testuser', reason: null, deletionDate: DATE});
|
||||
expect(body).not.toContain('Reason:');
|
||||
expect(body).not.toContain('\n\n\n');
|
||||
},
|
||||
);
|
||||
it('renders a blank reason the same as no reason', async () => {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(
|
||||
{
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Fluxer',
|
||||
appBaseUrl: 'https://example.com',
|
||||
marketingBaseUrl: 'https://example.com',
|
||||
},
|
||||
new EmailI18nService(),
|
||||
provider,
|
||||
);
|
||||
await service.sendUnbanNotification('[email protected]', 'testuser', ' ', 'en-US');
|
||||
await service.sendUnbanNotification('[email protected]', 'testuser', null, 'en-US');
|
||||
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', ' ', DATE, 'en-US');
|
||||
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', null, DATE, 'en-US');
|
||||
expect(sent).toHaveLength(4);
|
||||
expect(sent[0].text).toBe(sent[1].text);
|
||||
expect(sent[0].text).not.toContain('Reason:');
|
||||
expect(sent[2].text).toBe(sent[3].text);
|
||||
expect(sent[2].text).not.toContain('Reason:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
|
||||
export const EMAIL_I18N_MESSAGES = {
|
||||
account_deletion_cancelled: {
|
||||
subject: 'Your {product_name} account is no longer scheduled for deletion',
|
||||
body: 'Hello {username},\n\nThe scheduled deletion of your {product_name} account has been cancelled. Your account will not be deleted.\n\nIf you have any questions, contact {safety_email}.\n\n– {product_name} Team',
|
||||
},
|
||||
account_deletion_scheduled_inactivity: {
|
||||
subject: 'Your {product_name} account will be deleted due to inactivity',
|
||||
body: 'Hello {username},\n\nYour {product_name} account has been inactive for a long time, so it is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nIf you want to keep your account, contact {safety_email} from this email address before that date.\n\n– {product_name} Team',
|
||||
},
|
||||
account_deletion_scheduled_requested: {
|
||||
subject: 'Your {product_name} account deletion is scheduled',
|
||||
body: "Hello {username},\n\nAs you requested, your {product_name} account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nYour account is locked until then. If you didn't request this, or you want to keep your account, contact {safety_email} from this email address before that date.\n\n– {product_name} Team",
|
||||
},
|
||||
account_disabled_suspicious: {
|
||||
subject: 'Your {product_name} account has been temporarily disabled',
|
||||
body: "Hello {username},\n\nWe temporarily disabled your {product_name} account because we detected suspicious activity.\n\n{reason, select,\n null {}\n other {Reason: {reason}}\n}\n\nTo regain access to your account, you'll need to reset your password:\n\n{forgotUrl}\n\nAfter you reset your password, you'll be able to log in again.\n\nIf you believe this was done in error, please contact our support team.\n\n– {product_name} Safety Team",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export type EmailTemplateKey =
|
||||
| 'account_deletion_cancelled'
|
||||
| 'account_deletion_scheduled_inactivity'
|
||||
| 'account_deletion_scheduled_requested'
|
||||
| 'account_disabled_suspicious'
|
||||
| 'account_scheduled_deletion'
|
||||
| 'account_temp_banned'
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface EmailTemplateVariables {
|
||||
account_deletion_cancelled: {
|
||||
username: string;
|
||||
};
|
||||
account_deletion_scheduled_inactivity: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
deletionDate: Date;
|
||||
};
|
||||
account_deletion_scheduled_requested: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
deletionDate: Date;
|
||||
};
|
||||
account_disabled_suspicious: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
@@ -103,7 +116,7 @@ export interface EmailTemplateVariables {
|
||||
scheduled_deletion_notification: {
|
||||
username: string;
|
||||
deletionDate: Date;
|
||||
reason: string;
|
||||
reason: string | null;
|
||||
};
|
||||
self_deletion_scheduled: {
|
||||
username: string;
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "تم إلغاء حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nتم إلغاء الحذف المجدول لحسابك في {product_name}. لن يتم حذف حسابك.\n\nإذا كانت لديك أي أسئلة، يرجى الاتصال بـ{safety_email}.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
|
||||
"body": "مرحبًا {username}،\n\nلم يكن حسابك في {product_name} نشطًا لفترة طويلة، لذا تمت جدولته للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nإذا كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "تمت جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nبناءً على طلبك، تمت جدولة حسابك في {product_name} للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nسيظل حسابك مقفلًا حتى ذلك الحين. إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "تم تعطيل حسابك في {product_name} مؤقتًا",
|
||||
"body": "مرحبًا {username}،\n\nلقد قمنا بتعطيل حسابك في {product_name} مؤقتًا لأننا اكتشفنا نشاطًا مشبوهًا.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nلاستعادة الوصول إلى حسابك، ستحتاج إلى إعادة تعيين كلمة المرور الخاصة بك:\n\n{forgotUrl}\n\nبعد إعادة تعيين كلمة المرور الخاصة بك، ستتمكن من تسجيل الدخول مرة أخرى.\n\nإذا كنت تعتقد أن هذا حدث عن طريق الخطأ، يرجى الاتصال بفريق الدعم لدينا.\n\n– فريق أمان {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "إعادة تعيين كلمة المرور في {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Изтриването на акаунта ти във {product_name} е отменено",
|
||||
"body": "Здравей, {username},\n\nНасроченото изтриване на акаунта ти във {product_name} е отменено. Акаунтът ти няма да бъде изтрит.\n\nАко имаш въпроси, свържи се с {safety_email}.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е неактивен от дълго време, затова е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nАко искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Изтриването на акаунта ти във {product_name} е насрочено",
|
||||
"body": "Здравей, {username},\n\nКакто поиска, акаунтът ти във {product_name} е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nДотогава акаунтът ти е заключен. Ако не си поискал това или искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Акаунтът ти във {product_name} е временно деактивиран",
|
||||
"body": "Здравей, {username},\n\nВременно деактивирахме акаунта ти във {product_name}, защото открихме подозрителна активност.\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nЗа да възстановиш достъпа до акаунта си, трябва да нулираш паролата си:\n\n{forgotUrl}\n\nСлед като нулираш паролата си, ще можеш да влезеш отново.\n\nАко смяташ, че това е грешка, свържи се с нашия екип за поддръжка.\n\n– Екип за безопасност на {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Потвърди промяната на паролата си във {product_name}",
|
||||
"body": "Здравей, {username},\n\nПолучихме искане за промяна на паролата на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича в {expiresAt}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно и активирай двуфакторно удостоверяване.\n\n– Екип на {product_name}"
|
||||
"body": "Здравей, {username},\n\nПолучихме искане за промяна на паролата на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно и активирай двуфакторно удостоверяване.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Нулирай паролата си във {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Smazání vašeho účtu {product_name} bylo zrušeno",
|
||||
"body": "Dobrý den, {username},\n\nNaplánované smazání vašeho účtu {product_name} bylo zrušeno. Váš účet nebude smazán.\n\nMáte-li jakékoli dotazy, kontaktujte {safety_email}.\n\n– Tým {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Váš účet {product_name} bude smazán kvůli neaktivitě",
|
||||
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dlouho neaktivní, a proto je jeho trvalé smazání naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}{reason, select, null {} other {\n\nDůvod: {reason}}}\n\nPokud si chcete účet ponechat, kontaktujte před tímto datem {safety_email} z této e-mailové adresy.\n\n– Tým {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Smazání vašeho účtu {product_name} je naplánováno",
|
||||
"body": "Dobrý den, {username},\n\nNa vaši žádost je trvalé smazání vašeho účtu {product_name} naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}{reason, select, null {} other {\n\nDůvod: {reason}}}\n\nDo té doby je váš účet uzamčen. Pokud jste o smazání nežádali nebo si chcete účet ponechat, kontaktujte před tímto datem {safety_email} z této e-mailové adresy.\n\n– Tým {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Váš účet {product_name} byl dočasně deaktivován",
|
||||
"body": "Dobrý den, {username},\n\nDočasně jsme deaktivovali váš účet {product_name}, protože jsme zaznamenali podezřelou aktivitu.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nAbyste znovu získali přístup k účtu, musíte si nastavit nové heslo:\n\n{forgotUrl}\n\nPo změně hesla se budete moci znovu přihlásit.\n\nPokud se domníváte, že jde o chybu, kontaktujte náš tým podpory.\n\n– Bezpečnostní tým {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potvrďte změnu hesla k účtu {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o změnu hesla k vašemu účtu {product_name}.\n\nZměnu potvrďte zadáním tohoto kódu v aplikaci:\n\n{code}\n\nPlatnost tohoto kódu vyprší v {expiresAt}.\n\nPokud jste o to nežádali, někdo může mít přístup k vašemu účtu. Okamžitě si změňte heslo a zapněte dvoufaktorové ověřování.\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o změnu hesla k vašemu účtu {product_name}.\n\nZměnu potvrďte zadáním tohoto kódu v aplikaci:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste o to nežádali, někdo může mít přístup k vašemu účtu. Okamžitě si změňte heslo a zapněte dvoufaktorové ověřování.\n\n– Tým {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Obnovte své heslo k účtu {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Sletningen af din {product_name}-konto er annulleret",
|
||||
"body": "Hej {username},\n\nDen planlagte sletning af din {product_name}-konto er annulleret. Din konto bliver ikke slettet.\n\nHvis du har spørgsmål, kan du kontakte {safety_email}.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Din {product_name}-konto slettes på grund af inaktivitet",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto har været inaktiv i lang tid, så den slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsag: {reason}}}\n\nHvis du vil beholde din konto, skal du kontakte {safety_email} fra denne e-mailadresse inden denne dato.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Sletningen af din {product_name}-konto er planlagt",
|
||||
"body": "Hej {username},\n\nSom du har anmodet om, slettes din {product_name}-konto permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsag: {reason}}}\n\nDin konto er låst indtil da. Hvis du ikke har anmodet om dette, eller hvis du vil beholde din konto, skal du kontakte {safety_email} fra denne e-mailadresse inden denne dato.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Din {product_name}-konto er midlertidigt deaktiveret",
|
||||
"body": "Hej {username},\n\nVi har midlertidigt deaktiveret din {product_name}-konto, fordi vi har registreret mistænkelig aktivitet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nFor at få adgang til din konto igen skal du nulstille din adgangskode:\n\n{forgotUrl}\n\nNår du har nulstillet din adgangskode, kan du logge ind igen.\n\nHvis du mener, at dette er sket ved en fejl, kan du kontakte vores supportteam.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekræft ændringen af din adgangskode til {product_name}",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber kl. {expiresAt}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver tofaktorgodkendelse.\n\n– Teamet bag {product_name}"
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver tofaktorgodkendelse.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Nulstil din {product_name}-adgangskode",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Dein {product_name}-Account ist nicht mehr zur Löschung vorgemerkt",
|
||||
"body": "Hallo {username},\n\ndie geplante Löschung deines {product_name}-Accounts wurde abgebrochen. Dein Account wird nicht gelöscht.\n\nWenn du Fragen hast, kontaktiere {safety_email}.\n\n– {product_name}-Team"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Dein {product_name}-Account wird wegen Inaktivität gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account war lange inaktiv und wird deshalb an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}{reason, select, null {} other {\n\nGrund: {reason}}}\n\nWenn du deinen Account behalten möchtest, kontaktiere vor diesem Termin {safety_email} von dieser E-Mail-Adresse aus.\n\n– {product_name}-Team"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
|
||||
"body": "Hallo {username},\n\nwie von dir beantragt, wird dein {product_name}-Account an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}{reason, select, null {} other {\n\nGrund: {reason}}}\n\nDein Account ist bis dahin gesperrt. Wenn du das nicht beantragt hast oder deinen Account behalten möchtest, kontaktiere vor diesem Termin {safety_email} von dieser E-Mail-Adresse aus.\n\n– {product_name}-Team"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Dein {product_name}-Account wurde vorübergehend deaktiviert",
|
||||
"body": "Hallo {username},\n\nwir haben deinen {product_name}-Account vorübergehend deaktiviert, da wir verdächtige Aktivitäten festgestellt haben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nUm wieder Zugriff auf deinen Account zu erhalten, musst du dein Passwort zurücksetzen:\n\n{forgotUrl}\n\nNachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.\n\nWenn du glaubst, dass dies ein Fehler war, kontaktiere bitte unser Support-Team.\n\n– {product_name}-Sicherheitsteam"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bestätige deine {product_name}-Passwortänderung",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name}-Team"
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name}-Team"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Setze dein {product_name}-Passwort zurück",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} ακυρώθηκε",
|
||||
"body": "Γεια σου {username},\n\nΗ προγραμματισμένη διαγραφή του λογαριασμού σου στο {product_name} ακυρώθηκε. Ο λογαριασμός σου δεν θα διαγραφεί.\n\nΕάν έχεις ερωτήσεις, στείλε email στο {safety_email}.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί λόγω αδράνειας",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} είναι ανενεργός για μεγάλο χρονικό διάστημα, γι' αυτό έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}{reason, select, null {} other {\n\nΛόγος: {reason}}}\n\nΕάν θέλεις να διατηρήσεις τον λογαριασμό σου, στείλε email στο {safety_email} από αυτή τη διεύθυνση email πριν από αυτή την ημερομηνία.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} είναι προγραμματισμένη",
|
||||
"body": "Γεια σου {username},\n\nΌπως ζήτησες, ο λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}{reason, select, null {} other {\n\nΛόγος: {reason}}}\n\nΜέχρι τότε ο λογαριασμός σου είναι κλειδωμένος. Εάν δεν το ζήτησες ή θέλεις να διατηρήσεις τον λογαριασμό σου, στείλε email στο {safety_email} από αυτή τη διεύθυνση email πριν από αυτή την ημερομηνία.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} έχει απενεργοποιηθεί προσωρινά",
|
||||
"body": "Γεια σου {username},\n\nΑπενεργοποιήσαμε προσωρινά τον λογαριασμό σου στο {product_name} επειδή εντοπίσαμε ύποπτη δραστηριότητα.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΓια να αποκτήσεις ξανά πρόσβαση στον λογαριασμό σου, θα πρέπει να επαναφέρεις τον κωδικό πρόσβασής σου:\n\n{forgotUrl}\n\nΑφού επαναφέρεις τον κωδικό πρόσβασής σου, θα μπορείς να συνδεθείς ξανά.\n\nΕάν πιστεύεις ότι αυτό έγινε κατά λάθος, επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Επιβεβαίωσε την αλλαγή κωδικού πρόσβασης στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt}.\n\nΕάν δεν το ζήτησες, μπορεί κάποιος να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Η ομάδα του {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορεί κάποιος να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Επανάφερε τον κωδικό πρόσβασής σου στο {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Your {product_name} account is no longer scheduled for deletion",
|
||||
"body": "Hello {username},\n\nThe scheduled deletion of your {product_name} account has been cancelled. Your account will not be deleted.\n\nIf you have any questions, contact {safety_email}.\n\n– {product_name} Team"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Your {product_name} account will be deleted due to inactivity",
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been inactive for a long time, so it is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nIf you want to keep your account, contact {safety_email} from this email address before that date.\n\n– {product_name} Team"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Your {product_name} account deletion is scheduled",
|
||||
"body": "Hello {username},\n\nAs you requested, your {product_name} account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nYour account is locked until then. If you didn't request this, or you want to keep your account, contact {safety_email} from this email address before that date.\n\n– {product_name} Team"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Your {product_name} account has been temporarily disabled",
|
||||
"body": "Hello {username},\n\nWe temporarily disabled your {product_name} account because we detected suspicious activity.\n\n{reason, select,\n null {}\n other {Reason: {reason}}\n}\n\nTo regain access to your account, you'll need to reset your password:\n\n{forgotUrl}\n\nAfter you reset your password, you'll be able to log in again.\n\nIf you believe this was done in error, please contact our support team.\n\n– {product_name} Safety Team"
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Se canceló la eliminación de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nSe canceló la eliminación programada de tu cuenta de {product_name}. Tu cuenta no será eliminada.\n\nSi tienes alguna pregunta, comunícate con {safety_email}.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada debido a inactividad",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que su eliminación permanente está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nSi quieres conservar tu cuenta, comunícate con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSegún lo que solicitaste, la eliminación permanente de tu cuenta de {product_name} está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nTu cuenta permanecerá bloqueada hasta entonces. Si no solicitaste esto, o si quieres conservar tu cuenta, comunícate con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido deshabilitada temporalmente",
|
||||
"body": "Hola {username}:\n\nDeshabilitamos temporalmente tu cuenta de {product_name} porque detectamos actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, deberás restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto fue un error, comunícate con nuestro equipo de soporte.\n\n– Equipo de seguridad de {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirma tu cambio de contraseña de {product_name}",
|
||||
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence a las {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y habilita la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y habilita la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Restablece tu contraseña de {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Se ha cancelado la eliminación de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nSe ha cancelado la eliminación programada de tu cuenta de {product_name}. Tu cuenta no se eliminará.\n\nSi tienes alguna pregunta, ponte en contacto con {safety_email}.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada debido a la inactividad",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que su eliminación permanente está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nSi quieres conservar tu cuenta, ponte en contacto con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nTal y como solicitaste, la eliminación permanente de tu cuenta de {product_name} está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nTu cuenta permanecerá bloqueada hasta entonces. Si no solicitaste esto, o si quieres conservar tu cuenta, ponte en contacto con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido deshabilitada temporalmente",
|
||||
"body": "Hola {username}:\n\nHemos deshabilitado temporalmente tu cuenta de {product_name} porque hemos detectado actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, tendrás que restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto ha sido un error, ponte en contacto con nuestro equipo de soporte.\n\n– Equipo de seguridad de {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirma tu cambio de contraseña en {product_name}",
|
||||
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y activa la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y activa la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Restablece tu contraseña en {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "{product_name}-tilisi poistaminen on peruttu",
|
||||
"body": "Hei {username},\n\n{product_name}-tilisi ajoitettu poisto on peruttu. Tiliäsi ei poisteta.\n\nJos sinulla on kysyttävää, ota yhteyttä osoitteeseen {safety_email}.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-tilisi poistetaan käyttämättömyyden vuoksi",
|
||||
"body": "Hei {username},\n\n{product_name}-tilisi on ollut pitkään käyttämättä, joten se on ajoitettu poistettavaksi pysyvästi:\n\n{deletionDate, date, full} klo {deletionDate, time, short}{reason, select, null {} other {\n\nSyy: {reason}}}\n\nJos haluat säilyttää tilisi, ota yhteyttä osoitteeseen {safety_email} tästä sähköpostiosoitteesta ennen tätä päivämäärää.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name}-tilisi poistaminen on ajoitettu",
|
||||
"body": "Hei {username},\n\nPyyntösi mukaisesti {product_name}-tilisi on ajoitettu poistettavaksi pysyvästi:\n\n{deletionDate, date, full} klo {deletionDate, time, short}{reason, select, null {} other {\n\nSyy: {reason}}}\n\nTilisi on lukittu siihen asti. Jos et pyytänyt tätä tai haluat säilyttää tilisi, ota yhteyttä osoitteeseen {safety_email} tästä sähköpostiosoitteesta ennen tätä päivämäärää.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa on tilapäisesti poistettu käytöstä",
|
||||
"body": "Hei {username},\n\nOlemme poistaneet {product_name}-tilisi tilapäisesti käytöstä, koska havaitsimme epäilyttävää toimintaa.\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nPäästäksesi takaisin tilillesi sinun on nollattava salasanasi:\n\n{forgotUrl}\n\nKun olet nollannut salasanasi, voit kirjautua sisään uudelleen.\n\nJos uskot, että tämä oli virhe, ota yhteyttä tukitiimiimme.\n\n– {product_name}-turvallisuustiimi"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Vahvista salasanan muutos {product_name}-tilillä",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi muutoksen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi muutoksen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Nollaa {product_name}-salasanasi",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "La suppression de votre compte {product_name} a été annulée",
|
||||
"body": "Bonjour {username},\n\nLa suppression programmée de votre compte {product_name} a été annulée. Votre compte ne sera pas supprimé.\n\nPour toute question, contactez {safety_email}.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Votre compte {product_name} sera supprimé pour inactivité",
|
||||
"body": "Bonjour {username},\n\nVotre compte {product_name} est inactif depuis longtemps. Sa suppression définitive est donc prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}{reason, select, null {} other {\n\nMotif : {reason}}}\n\nSi vous souhaitez conserver votre compte, contactez {safety_email} depuis cette adresse e-mail avant cette date.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La suppression de votre compte {product_name} est programmée",
|
||||
"body": "Bonjour {username},\n\nComme vous l’avez demandé, la suppression définitive de votre compte {product_name} est prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}{reason, select, null {} other {\n\nMotif : {reason}}}\n\nVotre compte est verrouillé d’ici là. Si vous n’êtes pas à l’origine de cette demande, ou si vous souhaitez conserver votre compte, contactez {safety_email} depuis cette adresse e-mail avant cette date.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Votre compte {product_name} a été temporairement désactivé",
|
||||
"body": "Bonjour {username},\n\nNous avons temporairement désactivé votre compte {product_name} après avoir détecté une activité suspecte.\n\n{reason, select, null {} other {Motif : {reason}}}\n\nPour retrouver l’accès à votre compte, réinitialisez votre mot de passe :\n\n{forgotUrl}\n\nVous pourrez ensuite vous connecter à nouveau.\n\nSi vous pensez qu’il s’agit d’une erreur, contactez notre équipe d’assistance.\n\n– L’équipe de sécurité de {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirmez la modification de votre mot de passe {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification du mot de passe de votre compte {product_name}.\n\nPour confirmer ce changement, saisissez ce code dans l’application :\n\n{code}\n\nCe code expire à {expiresAt}.\n\nSi vous n’êtes pas à l’origine de cette demande, quelqu’un a peut-être accès à votre compte. Changez immédiatement votre mot de passe et activez l’authentification à deux facteurs.\n\n– L’équipe {product_name}"
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification du mot de passe de votre compte {product_name}.\n\nPour confirmer ce changement, saisissez ce code dans l’application :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi vous n’êtes pas à l’origine de cette demande, quelqu’un a peut-être accès à votre compte. Changez immédiatement votre mot de passe et activez l’authentification à deux facteurs.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Réinitialiser votre mot de passe {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "מחיקת חשבון ה-{product_name} שלכם בוטלה",
|
||||
"body": "שלום {username},\n\nהמחיקה המתוזמנת של חשבון ה-{product_name} שלכם בוטלה. החשבון שלכם לא יימחק.\n\nאם יש לכם שאלות, צרו קשר עם {safety_email}.\n\nצוות {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק עקב חוסר פעילות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם לא היה פעיל זמן רב, ולכן הוא מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}{reason, select, null {} other {\n\nסיבה: {reason}}}\n\nאם ברצונכם לשמור על החשבון, צרו קשר עם {safety_email} מכתובת האימייל הזו לפני תאריך זה.\n\nצוות {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "מחיקת חשבון ה-{product_name} שלכם מתוזמנת",
|
||||
"body": "שלום {username},\n\nבהתאם לבקשתכם, חשבון ה-{product_name} שלכם מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}{reason, select, null {} other {\n\nסיבה: {reason}}}\n\nעד אז החשבון שלכם נעול. אם לא ביקשתם זאת, או שברצונכם לשמור על החשבון, צרו קשר עם {safety_email} מכתובת האימייל הזו לפני תאריך זה.\n\nצוות {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "חשבון ה-{product_name} שלכם הושבת זמנית",
|
||||
"body": "שלום {username},\n\nהשבתנו זמנית את חשבון ה-{product_name} שלכם מכיוון שזיהינו פעילות חשודה.\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nכדי לשחזר את הגישה לחשבון, תצטרכו לאפס את הסיסמה:\n\n{forgotUrl}\n\nלאחר שתאפסו את הסיסמה, תוכלו להתחבר שוב.\n\nאם אתם חושבים שזו טעות, צרו קשר עם צוות התמיכה שלנו.\n\nצוות הבטיחות של {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "אישור שינוי הסיסמה שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nקיבלנו בקשה לשינוי הסיסמה בחשבון ה-{product_name} שלכם.\n\nכדי לאשר שינוי זה, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג ב-{expiresAt}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד והפעילו אימות דו-שלבי.\n\nצוות {product_name}"
|
||||
"body": "שלום {username},\n\nקיבלנו בקשה לשינוי הסיסמה בחשבון ה-{product_name} שלכם.\n\nכדי לאשר שינוי זה, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג בתאריך {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד והפעילו אימות דו-שלבי.\n\nצוות {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "איפוס הסיסמה שלכם ב-{product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "आपके {product_name} अकाउंट का डिलीशन रद्द कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट का शेड्यूल किया गया डिलीशन रद्द कर दिया गया है। आपका अकाउंट डिलीट नहीं किया जाएगा।\n\nअगर आपके कोई सवाल हैं, तो कृपया {safety_email} पर संपर्क करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "आपका {product_name} अकाउंट इनएक्टिविटी के कारण डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपका {product_name} अकाउंट लंबे समय से इनएक्टिव है, इसलिए इसे हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर{reason, select, null {} other {\n\nवजह: {reason}}}\n\nअगर आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इसी ईमेल पते से {safety_email} पर संपर्क करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "आपके {product_name} अकाउंट का डिलीशन शेड्यूल कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके अनुरोध के अनुसार, आपके {product_name} अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर{reason, select, null {} other {\n\nवजह: {reason}}}\n\nतब तक आपका अकाउंट लॉक रहेगा। अगर आपने इसका अनुरोध नहीं किया था, या आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इसी ईमेल पते से {safety_email} पर संपर्क करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "आपका {product_name} अकाउंट अस्थायी रूप से डिसेबल कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nहमने आपके {product_name} अकाउंट को अस्थायी रूप से डिसेबल कर दिया है क्योंकि हमने संदिग्ध एक्टिविटी देखी है।\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nअपने अकाउंट का एक्सेस फिर से पाने के लिए, आपको अपना पासवर्ड रीसेट करना होगा:\n\n{forgotUrl}\n\nअपना पासवर्ड रीसेट करने के बाद, आप फिर से लॉगिन कर पाएँगे।\n\nअगर आपको लगता है कि यह गलती से हुआ है, तो कृपया हमारी सपोर्ट टीम से संपर्क करें।\n\n– {product_name} सेफ्टी टीम"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "अपने {product_name} पासवर्ड में बदलाव की पुष्टि करें",
|
||||
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट पर पासवर्ड बदलने का अनुरोध मिला है।\n\nइस बदलाव की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसका अनुरोध नहीं किया था, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें और टू-फ़ैक्टर ऑथेंटिकेशन इनेबल करें।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट पर पासवर्ड बदलने का अनुरोध मिला है।\n\nइस बदलाव की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसका अनुरोध नहीं किया था, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें और टू-फ़ैक्टर ऑथेंटिकेशन इनेबल करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "अपना {product_name} पासवर्ड रीसेट करें",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Brisanje tvog računa na {product_name}u je otkazano",
|
||||
"body": "Pozdrav {username},\n\nZakazano brisanje tvog računa na {product_name}u je otkazano. Tvoj račun neće biti izbrisan.\n\nAko imaš pitanja, kontaktiraj {safety_email}.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tvoj račun na {product_name}u bit će izbrisan zbog neaktivnosti",
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na {product_name}u dugo je bio neaktivan, pa je zakazan za trajno brisanje:\n\n{deletionDate, date, full} u {deletionDate, time, short}{reason, select, null {} other {\n\nRazlog: {reason}}}\n\nAko želiš zadržati račun, kontaktiraj {safety_email} s ove adrese e-pošte prije tog datuma.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Brisanje tvog računa na {product_name}u je zakazano",
|
||||
"body": "Pozdrav {username},\n\nNa tvoj zahtjev, tvoj račun na {product_name}u zakazan je za trajno brisanje:\n\n{deletionDate, date, full} u {deletionDate, time, short}{reason, select, null {} other {\n\nRazlog: {reason}}}\n\nDo tada je tvoj račun zaključan. Ako ovaj zahtjev ne dolazi od tebe ili želiš zadržati račun, kontaktiraj {safety_email} s ove adrese e-pošte prije tog datuma.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tvoj račun na {product_name}u privremeno je onemogućen",
|
||||
"body": "Pozdrav {username},\n\nPrivremeno smo onemogućili tvoj račun na {product_name}u jer smo otkrili sumnjivu aktivnost.\n\n{reason, select,\n null {}\n other {Razlog: {reason}}\n}\n\nZa ponovni pristup svom računu morat ćeš poništiti lozinku:\n\n{forgotUrl}\n\nNakon što poništiš lozinku, moći ćeš se ponovno prijaviti.\n\nAko misliš da je ovo pogreška, kontaktiraj naš tim za podršku.\n\n– Sigurnosni tim {product_name}a"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potvrdi promjenu lozinke za {product_name}",
|
||||
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za promjenu lozinke na tvom računu na {product_name}u.\n\nZa potvrdu ove promjene unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod vrijedi do {expiresAt}.\n\nAko ovaj zahtjev ne dolazi od tebe, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku i omogući dvofaktorsku autentifikaciju.\n\n– Tim {product_name}a"
|
||||
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za promjenu lozinke na tvom računu na {product_name}u.\n\nZa potvrdu ove promjene unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod istječe {expiresAt, date, full} u {expiresAt, time, short}.\n\nAko ovaj zahtjev ne dolazi od tebe, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku i omogući dvofaktorsku autentifikaciju.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Poništi svoju lozinku za {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Visszavontuk {product_name}-fiókod törlését",
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókod ütemezett törlését visszavontuk. A fiókodat nem töröljük.\n\nHa kérdésed van, írj erre a címre: {safety_email}.\n\n– {product_name} csapata"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-fiókodat inaktivitás miatt törölni fogjuk",
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókod hosszú ideje inaktív, ezért végleges törlése a következő időpontra van ütemezve:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nIndok: {reason}}}\n\nHa meg szeretnéd tartani a fiókodat, még az időpont előtt írj erről az e-mail-címről erre a címre: {safety_email}.\n\n– {product_name} csapata"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Ütemeztük {product_name}-fiókod törlését",
|
||||
"body": "Szia, {username}!\n\nKérésedre {product_name}-fiókod végleges törlése a következő időpontra van ütemezve:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nIndok: {reason}}}\n\nAddig a fiókod zárolva van. Ha nem te kérted ezt, vagy meg szeretnéd tartani a fiókodat, még az időpont előtt írj erről az e-mail-címről erre a címre: {safety_email}.\n\n– {product_name} csapata"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "{product_name}-fiókodat ideiglenesen letiltottuk",
|
||||
"body": "Szia, {username}!\n\nIdeiglenesen letiltottuk a {product_name}-fiókodat, mert gyanús tevékenységet észleltünk.\n\n{reason, select,\n null {}\n other {Indok: {reason}}\n}\n\nA fiókodhoz való hozzáférés visszaszerzéséhez vissza kell állítanod a jelszavadat:\n\n{forgotUrl}\n\nA jelszó visszaállítása után újra be tudsz jelentkezni.\n\nHa úgy gondolod, hogy ez tévedésből történt, vedd fel a kapcsolatot az ügyfélszolgálatunkkal.\n\n– {product_name} biztonsági csapata"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Erősítsd meg {product_name}-fiókod jelszavának módosítását",
|
||||
"body": "Szia, {username}!\n\nKérést kaptunk {product_name}-fiókod jelszavának módosítására.\n\nA módosítás megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nA kód lejárati időpontja: {expiresAt}.\n\nHa nem te kérted ezt, lehet, hogy valaki más is hozzáfér a fiókodhoz. Azonnal változtasd meg a jelszavadat, és kapcsold be a kétlépcsős azonosítást.\n\n– {product_name} csapata"
|
||||
"body": "Szia, {username}!\n\nKérést kaptunk {product_name}-fiókod jelszavának módosítására.\n\nA módosítás megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt, date, full} {expiresAt, time, short} időpontban jár le.\n\nHa nem te kérted ezt, lehet, hogy valaki más is hozzáfér a fiókodhoz. Azonnal változtasd meg a jelszavadat, és kapcsold be a kétlépcsős azonosítást.\n\n– {product_name} csapata"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Állítsd vissza {product_name}-fiókod jelszavát",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Penghapusan akun {product_name} kamu telah dibatalkan",
|
||||
"body": "Halo {username},\n\nPenghapusan terjadwal untuk akun {product_name} kamu telah dibatalkan. Akunmu tidak akan dihapus.\n\nJika kamu punya pertanyaan, silakan hubungi {safety_email}.\n\n– Tim {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Akun {product_name} kamu akan dihapus karena tidak aktif",
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu sudah lama tidak aktif, sehingga dijadwalkan untuk dihapus permanen pada:\n\n{deletionDate, date, full} pukul {deletionDate, time, short}{reason, select, null {} other {\n\nAlasan: {reason}}}\n\nJika kamu ingin mempertahankan akunmu, hubungi {safety_email} dari alamat email ini sebelum tanggal tersebut.\n\n– Tim {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Penghapusan akun {product_name} kamu telah dijadwalkan",
|
||||
"body": "Halo {username},\n\nSesuai permintaanmu, akun {product_name} kamu dijadwalkan untuk dihapus permanen pada:\n\n{deletionDate, date, full} pukul {deletionDate, time, short}{reason, select, null {} other {\n\nAlasan: {reason}}}\n\nAkunmu dikunci hingga saat itu. Jika kamu tidak meminta ini, atau ingin mempertahankan akunmu, hubungi {safety_email} dari alamat email ini sebelum tanggal tersebut.\n\n– Tim {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Akun {product_name} kamu telah dinonaktifkan sementara",
|
||||
"body": "Halo {username},\n\nKami menonaktifkan sementara akun {product_name} kamu karena kami mendeteksi aktivitas mencurigakan.\n\n{reason, select,\n null {}\n other {Alasan: {reason}}\n}\n\nUntuk mendapatkan kembali akses ke akunmu, kamu perlu mengatur ulang kata sandimu:\n\n{forgotUrl}\n\nSetelah mengatur ulang kata sandi, kamu bisa masuk lagi.\n\nJika kamu merasa ini keliru, silakan hubungi tim dukungan kami.\n\n– Tim Keamanan {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Konfirmasi perubahan kata sandi {product_name} kamu",
|
||||
"body": "Halo {username},\n\nKami menerima permintaan untuk mengubah kata sandi di akun {product_name} kamu.\n\nUntuk mengonfirmasi perubahan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera dan aktifkan autentikasi dua faktor.\n\n– Tim {product_name}"
|
||||
"body": "Halo {username},\n\nKami menerima permintaan untuk mengubah kata sandi di akun {product_name} kamu.\n\nUntuk mengonfirmasi perubahan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt, date, full} pukul {expiresAt, time, short}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera dan aktifkan autentikasi dua faktor.\n\n– Tim {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Atur ulang kata sandi {product_name} kamu",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "L'eliminazione del tuo account {product_name} è stata annullata",
|
||||
"body": "Ciao {username},\n\nL'eliminazione programmata del tuo account {product_name} è stata annullata. Il tuo account non verrà eliminato.\n\nPer qualsiasi domanda, contatta {safety_email}.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Il tuo account {product_name} verrà eliminato per inattività",
|
||||
"body": "Ciao {username},\n\nIl tuo account {product_name} è inattivo da molto tempo, quindi verrà eliminato definitivamente in questa data:\n\n{deletionDate, date, full} alle {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nSe vuoi mantenere il tuo account, contatta {safety_email} da questo indirizzo email prima di tale data.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "L'eliminazione del tuo account {product_name} è programmata",
|
||||
"body": "Ciao {username},\n\nCome da tua richiesta, il tuo account {product_name} verrà eliminato definitivamente in questa data:\n\n{deletionDate, date, full} alle {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nFino ad allora il tuo account è bloccato. Se non hai richiesto l'eliminazione o se vuoi mantenere il tuo account, contatta {safety_email} da questo indirizzo email prima di tale data.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Il tuo account {product_name} è stato temporaneamente disabilitato",
|
||||
"body": "Ciao {username},\n\nAbbiamo temporaneamente disabilitato il tuo account {product_name} perché abbiamo rilevato attività sospette.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPer riottenere l'accesso al tuo account, dovrai reimpostare la password:\n\n{forgotUrl}\n\nDopo aver reimpostato la password, potrai accedere di nuovo.\n\nSe ritieni che ciò sia stato fatto per errore, contatta il nostro team di assistenza.\n\n– Il team per la sicurezza di {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Conferma la modifica della password di {product_name}",
|
||||
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di modifica della password del tuo account {product_name}.\n\nPer confermare questa modifica, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade alle {expiresAt}.\n\nSe non hai richiesto questa modifica, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password e abilita l'autenticazione a due fattori.\n\n– Il team di {product_name}"
|
||||
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di modifica della password del tuo account {product_name}.\n\nPer confermare questa modifica, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade il {expiresAt, date, full} alle {expiresAt, time, short}.\n\nSe non hai richiesto questa modifica, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password e abilita l'autenticazione a due fattori.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Reimposta la password di {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "{product_name}アカウントの削除予定は取り消されました",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントの削除予定は取り消されました。アカウントは削除されません。\n\nご質問がございましたら、{safety_email}までお問い合わせください。\n\n– {product_name}チーム"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}アカウントは非アクティブのため削除されます",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは長期間利用されていないため、以下の日時に完全に削除される予定です。\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n理由: {reason}}}\n\nアカウントを維持したい場合は、この日時までにこのメールアドレスから{safety_email}へご連絡ください。\n\n– {product_name}チーム"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name}アカウントの削除が予定されています",
|
||||
"body": "こんにちは、{username}さん\n\nリクエストに基づき、{product_name}アカウントは以下の日時に完全に削除される予定です。\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n理由: {reason}}}\n\nそれまでアカウントはロックされます。ご自身でリクエストしていない場合、またはアカウントを維持したい場合は、この日時までにこのメールアドレスから{safety_email}へご連絡ください。\n\n– {product_name}チーム"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "{product_name}アカウントが一時的に無効になりました",
|
||||
"body": "こんにちは、{username}さん\n\n不審なアクティビティが検出されたため、{product_name}アカウントを一時的に無効にしました。\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nアカウントに再度アクセスするには、パスワードをリセットする必要があります。\n\n{forgotUrl}\n\nパスワードをリセットすると、再度ログインできるようになります。\n\nこの措置が誤りであると思われる場合は、サポートチームにお問い合わせください。\n\n– {product_name}安全チーム"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name}のパスワード変更を確認",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのパスワード変更リクエストを受け付けました。\n\nこの変更を確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードの有効期限は{expiresAt}です。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更し、2段階認証を有効にしてください。\n\n– {product_name}チーム"
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのパスワード変更リクエストを受け付けました。\n\nこの変更を確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードの有効期限は{expiresAt, date, full} {expiresAt, time, short}です。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更し、2段階認証を有効にしてください。\n\n– {product_name}チーム"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "{product_name}のパスワードをリセット",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "{product_name} 계정 삭제 예정이 취소되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 삭제 예정이 취소되었어요. 계정은 삭제되지 않아요.\n\n궁금한 점이 있다면 {safety_email}으로 문의해 주세요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "장기간 미접속으로 {product_name} 계정이 삭제될 예정이에요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정이 오랫동안 사용되지 않아 다음 날짜에 영구적으로 삭제될 예정이에요:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n사유: {reason}}}\n\n계정을 유지하고 싶다면 해당 날짜 전에 이 이메일 주소에서 {safety_email}으로 문의해 주세요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name} 계정 삭제가 예정되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n요청하신 대로 {product_name} 계정이 다음 날짜에 영구적으로 삭제될 예정이에요:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n사유: {reason}}}\n\n그때까지 계정은 잠겨 있어요. 요청하지 않으셨거나 계정을 유지하고 싶다면 해당 날짜 전에 이 이메일 주소에서 {safety_email}으로 문의해 주세요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "{product_name} 계정이 일시적으로 비활성화되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n의심스러운 활동이 감지되어 {product_name} 계정을 일시적으로 비활성화했어요.\n\n{reason, select,\n null {}\n other {사유: {reason}}\n}\n\n계정에 다시 액세스하려면 비밀번호를 재설정해야 해요:\n\n{forgotUrl}\n\n비밀번호를 재설정한 후 다시 로그인할 수 있어요.\n\n실수로 비활성화되었다고 생각되면 고객지원팀에 문의해 주세요.\n\n– {product_name} 안전팀"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name} 비밀번호 변경 확인",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 비밀번호 변경 요청이 접수되었어요.\n\n이 변경을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하고 2단계 인증을 활성화하세요.\n\n– {product_name} 팀"
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 비밀번호 변경 요청이 접수되었어요.\n\n이 변경을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt, date, full} {expiresAt, time, short}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하고 2단계 인증을 활성화하세요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "{product_name} 비밀번호 재설정",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Tavo {product_name} paskyros ištrynimas atšauktas",
|
||||
"body": "Sveiki, {username},\n\nNumatytas tavo {product_name} paskyros ištrynimas atšauktas. Tavo paskyra nebus ištrinta.\n\nJei turi klausimų, rašyk adresu {safety_email}.\n\n– {product_name} komanda"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tavo {product_name} paskyra bus ištrinta dėl neaktyvumo",
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyra ilgą laiką buvo neaktyvi, todėl ją numatyta visam laikui ištrinti:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nPriežastis: {reason}}}\n\nJei nori išsaugoti paskyrą, iki šios datos parašyk adresu {safety_email} iš šio el. pašto adreso.\n\n– {product_name} komanda"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Tavo {product_name} paskyros ištrynimas numatytas",
|
||||
"body": "Sveiki, {username},\n\nTavo prašymu {product_name} paskyrą numatyta visam laikui ištrinti:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nPriežastis: {reason}}}\n\nIki tol tavo paskyra užrakinta. Jei to neprašei arba nori išsaugoti paskyrą, iki šios datos parašyk adresu {safety_email} iš šio el. pašto adreso.\n\n– {product_name} komanda"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tavo {product_name} paskyra laikinai išjungta",
|
||||
"body": "Sveiki, {username},\n\nLaikinai išjungėme tavo {product_name} paskyrą, nes aptikome įtartiną veiklą.\n\n{reason, select,\n null {}\n other {Priežastis: {reason}}\n}\n\nKad atgautum prieigą prie paskyros, turėsi iš naujo nustatyti slaptažodį:\n\n{forgotUrl}\n\nKai iš naujo nustatysi slaptažodį, galėsi vėl prisijungti.\n\nJei manai, kad tai įvyko per klaidą, susisiek su mūsų palaikymo komanda.\n\n– {product_name} saugos komanda"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Patvirtink savo {product_name} slaptažodžio keitimą",
|
||||
"body": "Sveiki, {username},\n\nGavome prašymą pakeisti tavo {product_name} paskyros slaptažodį.\n\nKad patvirtintum šį pakeitimą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsiant pakeisk slaptažodį ir įjunk dviejų veiksnių autentifikavimą.\n\n– {product_name} komanda"
|
||||
"body": "Sveiki, {username},\n\nGavome prašymą pakeisti tavo {product_name} paskyros slaptažodį.\n\nKad patvirtintum šį pakeitimą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt, date, full} {expiresAt, time, short}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsiant pakeisk slaptažodį ir įjunk dviejų veiksnių autentifikavimą.\n\n– {product_name} komanda"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Iš naujo nustatyk savo {product_name} slaptažodį",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "De verwijdering van je {product_name}-account is geannuleerd",
|
||||
"body": "Hallo {username},\n\nDe geplande verwijdering van je {product_name}-account is geannuleerd. Je account wordt niet verwijderd.\n\nAls je vragen hebt, neem dan contact op met {safety_email}.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Je {product_name}-account wordt verwijderd wegens inactiviteit",
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account is al lange tijd inactief en staat daarom gepland voor permanente verwijdering op:\n\n{deletionDate, date, full} om {deletionDate, time, short}{reason, select, null {} other {\n\nReden: {reason}}}\n\nAls je je account wilt behouden, neem dan vóór die datum vanaf dit e-mailadres contact op met {safety_email}.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "De verwijdering van je {product_name}-account staat gepland",
|
||||
"body": "Hallo {username},\n\nZoals je hebt verzocht, staat je {product_name}-account gepland voor permanente verwijdering op:\n\n{deletionDate, date, full} om {deletionDate, time, short}{reason, select, null {} other {\n\nReden: {reason}}}\n\nJe account is tot die tijd vergrendeld. Als je dit niet hebt aangevraagd of je account wilt behouden, neem dan vóór die datum vanaf dit e-mailadres contact op met {safety_email}.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Je {product_name}-account is tijdelijk uitgeschakeld",
|
||||
"body": "Hallo {username},\n\nWe hebben je {product_name}-account tijdelijk uitgeschakeld omdat we verdachte activiteit hebben gedetecteerd.\n\n{reason, select,\n null {}\n other {Reden: {reason}}\n}\n\nOm weer toegang te krijgen tot je account, moet je je wachtwoord opnieuw instellen:\n\n{forgotUrl}\n\nNadat je je wachtwoord opnieuw hebt ingesteld, kun je weer inloggen.\n\nAls je denkt dat dit een fout is, neem dan contact op met ons supportteam.\n\n– Het veiligheidsteam van {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bevestig de wijziging van je wachtwoord op {product_name}",
|
||||
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om het wachtwoord van je {product_name}-account te wijzigen.\n\nOm deze wijziging te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt om {expiresAt}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord en schakel tweefactorauthenticatie in.\n\n– Het team van {product_name}"
|
||||
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om het wachtwoord van je {product_name}-account te wijzigen.\n\nOm deze wijziging te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt op {expiresAt, date, full} om {expiresAt, time, short}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord en schakel tweefactorauthenticatie in.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Stel je {product_name}-wachtwoord opnieuw in",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Slettingen av {product_name}-kontoen din er avbrutt",
|
||||
"body": "Hei {username},\n\nDen planlagte slettingen av {product_name}-kontoen din er avbrutt. Kontoen din blir ikke slettet.\n\nHvis du har spørsmål, kan du kontakte {safety_email}.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-kontoen din vil bli slettet på grunn av inaktivitet",
|
||||
"body": "Hei {username},\n\n{product_name}-kontoen din har vært inaktiv lenge, så den er planlagt for permanent sletting:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsak: {reason}}}\n\nHvis du vil beholde kontoen din, kan du kontakte {safety_email} fra denne e-postadressen før denne datoen.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Slettingen av {product_name}-kontoen din er planlagt",
|
||||
"body": "Hei {username},\n\nSom du ba om, er {product_name}-kontoen din planlagt for permanent sletting:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsak: {reason}}}\n\nKontoen din er låst frem til da. Hvis du ikke ba om dette, eller hvis du vil beholde kontoen din, kan du kontakte {safety_email} fra denne e-postadressen før denne datoen.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "{product_name}-kontoen din er midlertidig deaktivert",
|
||||
"body": "Hei {username},\n\nVi deaktiverte {product_name}-kontoen din midlertidig fordi vi oppdaget mistenkelig aktivitet.\n\n{reason, select,\n null {}\n other {Årsak: {reason}}\n}\n\nFor å få tilgang til kontoen din igjen må du tilbakestille passordet ditt:\n\n{forgotUrl}\n\nEtter at du har tilbakestilt passordet ditt, kan du logge inn igjen.\n\nHvis du mener dette er en feil, kan du kontakte kundestøtten vår.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekreft passordendringen for {product_name}",
|
||||
"body": "Hei {username},\n\nVi mottok en forespørsel om å endre passordet på {product_name}-kontoen din.\n\nSkriv inn denne koden i appen for å bekrefte endringen:\n\n{code}\n\nDenne koden utløper {expiresAt}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang og aktiver tofaktorautentisering.\n\n– {product_name}-teamet"
|
||||
"body": "Hei {username},\n\nVi mottok en forespørsel om å endre passordet på {product_name}-kontoen din.\n\nSkriv inn denne koden i appen for å bekrefte endringen:\n\n{code}\n\nDenne koden utløper {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang og aktiver tofaktorautentisering.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Tilbakestill {product_name}-passordet ditt",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Usunięcie Twojego konta {product_name} zostało anulowane",
|
||||
"body": "Witaj {username},\n\nZaplanowane usunięcie Twojego konta {product_name} zostało anulowane. Twoje konto nie zostanie usunięte.\n\nJeśli masz pytania, skontaktuj się z {safety_email}.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Twoje konto {product_name} zostanie usunięte z powodu braku aktywności",
|
||||
"body": "Witaj {username},\n\nTwoje konto {product_name} od dawna jest nieaktywne, dlatego zostało zaplanowane do trwałego usunięcia. Termin usunięcia:\n\n{deletionDate, date, full} o {deletionDate, time, short}{reason, select, null {} other {\n\nPowód: {reason}}}\n\nJeśli chcesz zachować konto, skontaktuj się z {safety_email} z tego adresu e-mail przed tym terminem.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Usunięcie Twojego konta {product_name} zostało zaplanowane",
|
||||
"body": "Witaj {username},\n\nZgodnie z Twoją prośbą zaplanowaliśmy trwałe usunięcie konta {product_name}. Termin usunięcia:\n\n{deletionDate, date, full} o {deletionDate, time, short}{reason, select, null {} other {\n\nPowód: {reason}}}\n\nDo tego czasu Twoje konto jest zablokowane. Jeśli nie prosiłeś o to lub chcesz zachować konto, skontaktuj się z {safety_email} z tego adresu e-mail przed tym terminem.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Twoje konto {product_name} zostało tymczasowo wyłączone",
|
||||
"body": "Witaj {username},\n\nTymczasowo wyłączyliśmy Twoje konto {product_name}, ponieważ wykryliśmy podejrzaną aktywność.\n\n{reason, select,\n null {}\n other {Powód: {reason}}\n}\n\nAby odzyskać dostęp do konta, musisz zresetować hasło:\n\n{forgotUrl}\n\nPo zresetowaniu hasła możesz ponownie się zalogować.\n\nJeśli uważasz, że to błąd, skontaktuj się z naszym zespołem wsparcia.\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potwierdź zmianę hasła w {product_name}",
|
||||
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o zmianę hasła do Twojego konta {product_name}.\n\nAby potwierdzić tę zmianę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa: {expiresAt}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło i włącz uwierzytelnianie dwuskładnikowe.\n\n– Zespół {product_name}"
|
||||
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o zmianę hasła do Twojego konta {product_name}.\n\nAby potwierdzić tę zmianę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa {expiresAt, date, full} o {expiresAt, time, short}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło i włącz uwierzytelnianie dwuskładnikowe.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Zresetuj hasło w {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "A exclusão da sua conta do {product_name} foi cancelada",
|
||||
"body": "Olá, {username},\n\nA exclusão agendada da sua conta do {product_name} foi cancelada. Sua conta não será excluída.\n\nSe tiver alguma dúvida, entre em contato com {safety_email}.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "A sua conta do {product_name} será excluída devido à inatividade",
|
||||
"body": "Olá, {username},\n\nSua conta do {product_name} está inativa há muito tempo, por isso está agendada para exclusão permanente em:\n\n{deletionDate, date, full} às {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nSe quiser manter sua conta, entre em contato com {safety_email} usando este endereço de e-mail antes dessa data.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "A exclusão da sua conta do {product_name} está agendada",
|
||||
"body": "Olá, {username},\n\nConforme solicitado, sua conta do {product_name} está agendada para exclusão permanente em:\n\n{deletionDate, date, full} às {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nSua conta permanecerá bloqueada até lá. Se você não solicitou isso, ou se quiser manter sua conta, entre em contato com {safety_email} usando este endereço de e-mail antes dessa data.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Sua conta do {product_name} foi desativada temporariamente",
|
||||
"body": "Olá, {username},\n\nDesativamos temporariamente sua conta do {product_name} porque detectamos atividades suspeitas.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara acessar sua conta novamente, você precisará redefinir sua senha:\n\n{forgotUrl}\n\nDepois de redefinir sua senha, você poderá entrar novamente.\n\nSe você acredita que isso foi um erro, entre em contato com nossa equipe de suporte.\n\n– equipe de segurança do {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirme a alteração da senha da sua conta do {product_name}",
|
||||
"body": "Olá, {username},\n\nRecebemos uma solicitação para alterar a senha da sua conta do {product_name}.\n\nPara confirmar esta alteração, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente e ative a autenticação de dois fatores.\n\n– Equipe do {product_name}"
|
||||
"body": "Olá, {username},\n\nRecebemos uma solicitação para alterar a senha da sua conta do {product_name}.\n\nPara confirmar esta alteração, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt, date, full} às {expiresAt, time, short}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente e ative a autenticação de dois fatores.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Redefina a senha da sua conta do {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Ștergerea contului {product_name} a fost anulată",
|
||||
"body": "Salut {username},\n\nȘtergerea programată a contului tău {product_name} a fost anulată. Contul tău nu va fi șters.\n\nDacă ai întrebări, te rugăm să scrii la {safety_email}.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Contul tău {product_name} va fi șters din cauza inactivității",
|
||||
"body": "Salut {username},\n\nContul tău {product_name} a fost inactiv o perioadă lungă, așa că este programat pentru ștergere permanentă la:\n\n{deletionDate, date, full} la ora {deletionDate, time, short}{reason, select, null {} other {\n\nMotiv: {reason}}}\n\nDacă vrei să îți păstrezi contul, scrie la {safety_email} de pe această adresă de e-mail înainte de această dată.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Ștergerea contului {product_name} a fost programată",
|
||||
"body": "Salut {username},\n\nConform solicitării tale, contul tău {product_name} este programat pentru ștergere permanentă la:\n\n{deletionDate, date, full} la ora {deletionDate, time, short}{reason, select, null {} other {\n\nMotiv: {reason}}}\n\nPână atunci, contul tău este blocat. Dacă nu ai solicitat acest lucru sau vrei să îți păstrezi contul, scrie la {safety_email} de pe această adresă de e-mail înainte de această dată.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Contul tău {product_name} a fost dezactivat temporar",
|
||||
"body": "Salut {username},\n\nAm dezactivat temporar contul tău {product_name} deoarece am detectat activitate suspectă.\n\n{reason, select,\n null {}\n other {Motiv: {reason}}\n}\n\nPentru a recăpăta accesul la cont, va trebui să îți resetezi parola:\n\n{forgotUrl}\n\nDupă ce îți resetezi parola, te vei putea conecta din nou.\n\nDacă crezi că este o greșeală, te rugăm să contactezi asistența.\n\n– Echipa de siguranță {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirmă schimbarea parolei pentru {product_name}",
|
||||
"body": "Salut {username},\n\nAm primit o solicitare de schimbare a parolei contului tău {product_name}.\n\nPentru a confirma această modificare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la {expiresAt}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat și activează autentificarea în doi pași.\n\n– Echipa {product_name}"
|
||||
"body": "Salut {username},\n\nAm primit o solicitare de schimbare a parolei contului tău {product_name}.\n\nPentru a confirma această modificare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la data de {expiresAt, date, full} la ora {expiresAt, time, short}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat și activează autentificarea în doi pași.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Resetează-ți parola pentru {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Удаление твоего аккаунта {product_name} отменено",
|
||||
"body": "Привет, {username},\n\nЗапланированное удаление твоего аккаунта {product_name} отменено. Твой аккаунт не будет удалён.\n\nЕсли у тебя есть вопросы, напиши на {safety_email}.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Твой аккаунт {product_name} будет удалён из-за неактивности",
|
||||
"body": "Привет, {username},\n\nТвой аккаунт {product_name} долгое время был неактивен, поэтому его безвозвратное удаление запланировано на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nЕсли хочешь сохранить аккаунт, напиши на {safety_email} с этого адреса электронной почты до этой даты.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Запланировано удаление твоего аккаунта {product_name}",
|
||||
"body": "Привет, {username},\n\nПо твоему запросу безвозвратное удаление аккаунта {product_name} запланировано на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nДо этого момента твой аккаунт заблокирован. Если ты не запрашивал удаление или хочешь сохранить аккаунт, напиши на {safety_email} с этого адреса электронной почты до этой даты.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Твой аккаунт {product_name} временно отключён",
|
||||
"body": "Привет, {username},\n\nМы временно отключили твой аккаунт {product_name}, так как обнаружили подозрительную активность.\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nЧтобы восстановить доступ к аккаунту, тебе нужно сбросить пароль:\n\n{forgotUrl}\n\nПосле сброса пароля ты сможешь снова войти в аккаунт.\n\nЕсли ты считаешь, что это ошибка, пожалуйста, свяжись с нашей службой поддержки.\n\n– Команда безопасности {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Подтверди смену пароля для {product_name}",
|
||||
"body": "Привет, {username},\n\nМы получили запрос на изменение пароля твоего аккаунта {product_name}.\n\nЧтобы подтвердить это изменение, введи этот код в приложении:\n\n{code}\n\nСрок действия кода истекает {expiresAt}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль и включи двухфакторную аутентификацию.\n\n– Команда {product_name}"
|
||||
"body": "Привет, {username},\n\nМы получили запрос на изменение пароля твоего аккаунта {product_name}.\n\nЧтобы подтвердить это изменение, введи этот код в приложении:\n\n{code}\n\nСрок действия кода истекает {expiresAt, date, full} в {expiresAt, time, short}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль и включи двухфакторную аутентификацию.\n\n– Команда {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Сбрось свой пароль для {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Raderingen av ditt {product_name}-konto har avbrutits",
|
||||
"body": "Hej {username},\n\nDen schemalagda raderingen av ditt {product_name}-konto har avbrutits. Ditt konto kommer inte att raderas.\n\nOm du har några frågor, kontakta {safety_email}.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Ditt {product_name}-konto kommer att raderas på grund av inaktivitet",
|
||||
"body": "Hej {username},\n\nDitt {product_name}-konto har varit inaktivt länge, så permanent radering av kontot är planerad till följande tidpunkt:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nAnledning: {reason}}}\n\nOm du vill behålla ditt konto, kontakta {safety_email} från den här e-postadressen före det datumet.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Raderingen av ditt {product_name}-konto är schemalagd",
|
||||
"body": "Hej {username},\n\nSom du har begärt är permanent radering av ditt {product_name}-konto planerad till följande tidpunkt:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nAnledning: {reason}}}\n\nDitt konto är låst fram till dess. Om du inte begärde detta, eller om du vill behålla ditt konto, kontakta {safety_email} från den här e-postadressen före det datumet.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Ditt {product_name}-konto har tillfälligt inaktiverats",
|
||||
"body": "Hej {username},\n\nVi har tillfälligt inaktiverat ditt {product_name}-konto eftersom vi upptäckte misstänkt aktivitet.\n\n{reason, select,\n null {}\n other {Anledning: {reason}}\n}\n\nFör att återfå åtkomst till ditt konto måste du återställa ditt lösenord:\n\n{forgotUrl}\n\nEfter att du har återställt ditt lösenord kan du logga in igen.\n\nOm du anser att detta har skett av misstag, kontakta vårt supportteam.\n\n– Säkerhetsteamet på {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekräfta ändringen av ditt lösenord på {product_name}",
|
||||
"body": "Hej {username},\n\nVi har fått en begäran om att ändra lösenordet för ditt {product_name}-konto.\n\nBekräfta ändringen genom att ange den här koden i appen:\n\n{code}\n\nDen här koden upphör att gälla {expiresAt}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart och aktivera tvåfaktorsautentisering.\n\n– {product_name}-teamet"
|
||||
"body": "Hej {username},\n\nVi har fått en begäran om att ändra lösenordet för ditt {product_name}-konto.\n\nBekräfta ändringen genom att ange den här koden i appen:\n\n{code}\n\nDen här koden upphör att gälla {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart och aktivera tvåfaktorsautentisering.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Återställ ditt lösenord på {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "การลบบัญชี {product_name} ของคุณถูกยกเลิกแล้ว",
|
||||
"body": "สวัสดี {username},\n\nการลบบัญชี {product_name} ของคุณที่กำหนดไว้ถูกยกเลิกแล้ว บัญชีของคุณจะไม่ถูกลบ\n\nหากคุณมีคำถามใด ๆ โปรดติดต่อ {safety_email}\n\n– ทีม {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "บัญชี {product_name} ของคุณจะถูกลบเนื่องจากไม่มีการใช้งาน",
|
||||
"body": "สวัสดี {username},\n\nบัญชี {product_name} ของคุณไม่มีการใช้งานมาเป็นเวลานาน จึงถูกกำหนดให้ลบอย่างถาวรในวันที่:\n\n{deletionDate, date, full} เวลา {deletionDate, time, short}{reason, select, null {} other {\n\nเหตุผล: {reason}}}\n\nหากคุณต้องการเก็บบัญชีไว้ โปรดติดต่อ {safety_email} จากที่อยู่อีเมลนี้ก่อนวันดังกล่าว\n\n– ทีม {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "บัญชี {product_name} ของคุณถูกกำหนดให้ลบแล้ว",
|
||||
"body": "สวัสดี {username},\n\nตามที่คุณร้องขอ บัญชี {product_name} ของคุณถูกกำหนดให้ลบอย่างถาวรในวันที่:\n\n{deletionDate, date, full} เวลา {deletionDate, time, short}{reason, select, null {} other {\n\nเหตุผล: {reason}}}\n\nบัญชีของคุณจะถูกล็อกจนถึงวันนั้น หากคุณไม่ได้ร้องขอ หรือต้องการเก็บบัญชีไว้ โปรดติดต่อ {safety_email} จากที่อยู่อีเมลนี้ก่อนวันดังกล่าว\n\n– ทีม {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "บัญชี {product_name} ของคุณถูกปิดใช้งานชั่วคราว",
|
||||
"body": "สวัสดี {username},\n\nเราปิดใช้งานบัญชี {product_name} ของคุณชั่วคราว เนื่องจากเราตรวจพบกิจกรรมที่น่าสงสัย\n\n{reason, select,\n null {}\n other {เหตุผล: {reason}}\n}\n\nหากต้องการเข้าถึงบัญชีอีกครั้ง ให้รีเซ็ตรหัสผ่าน:\n\n{forgotUrl}\n\nหลังจากรีเซ็ตรหัสผ่านแล้ว คุณจะสามารถเข้าสู่ระบบได้อีกครั้ง\n\nหากคุณเชื่อว่านี่เป็นข้อผิดพลาด โปรดติดต่อทีมสนับสนุนของเรา\n\n– ทีมความปลอดภัย {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "ยืนยันการเปลี่ยนรหัสผ่านสำหรับ {product_name}",
|
||||
"body": "สวัสดี {username},\n\nเราได้รับคำขอให้เปลี่ยนรหัสผ่านในบัญชี {product_name} ของคุณ\n\nหากต้องการยืนยันการเปลี่ยนแปลงนี้ ให้ป้อนรหัสนี้ในแอป:\n\n{code}\n\nรหัสนี้จะหมดอายุในเวลา {expiresAt}\n\nหากคุณไม่ได้ร้องขอ อาจมีผู้อื่นเข้าถึงบัญชีของคุณอยู่ เปลี่ยนรหัสผ่านทันทีและเปิดใช้งานการยืนยันตัวตนแบบสองขั้นตอน\n\n– ทีม {product_name}"
|
||||
"body": "สวัสดี {username},\n\nเราได้รับคำขอให้เปลี่ยนรหัสผ่านในบัญชี {product_name} ของคุณ\n\nหากต้องการยืนยันการเปลี่ยนแปลงนี้ ให้ป้อนรหัสนี้ในแอป:\n\n{code}\n\nรหัสนี้จะหมดอายุในวันที่ {expiresAt, date, full} เวลา {expiresAt, time, short}\n\nหากคุณไม่ได้ร้องขอ อาจมีผู้อื่นเข้าถึงบัญชีของคุณอยู่ เปลี่ยนรหัสผ่านทันทีและเปิดใช้งานการยืนยันตัวตนแบบสองขั้นตอน\n\n– ทีม {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "รีเซ็ตรหัสผ่านสำหรับ {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "{product_name} hesabının silinmesi iptal edildi",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabının planlanan silinme işlemi iptal edildi. Hesabın silinmeyecek.\n\nSoruların varsa {safety_email} adresine ulaş.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name} hesabın hareketsizlik nedeniyle silinecek",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabın uzun süredir hareketsiz olduğu için şu tarihte kalıcı olarak silinmek üzere planlandı:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nSebep: {reason}}}\n\nHesabını korumak istiyorsan bu tarihten önce bu e-posta adresinden {safety_email} adresine ulaş.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name} hesabının silinmesi planlandı",
|
||||
"body": "Merhaba {username},\n\nTalebin doğrultusunda {product_name} hesabın şu tarihte kalıcı olarak silinmek üzere planlandı:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nSebep: {reason}}}\n\nHesabın o zamana kadar kilitli. Bunu sen talep etmediysen ya da hesabını korumak istiyorsan bu tarihten önce bu e-posta adresinden {safety_email} adresine ulaş.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "{product_name} hesabın geçici olarak devre dışı bırakıldı",
|
||||
"body": "Merhaba {username},\n\nŞüpheli etkinlik tespit ettiğimiz için {product_name} hesabını geçici olarak devre dışı bıraktık.\n\n{reason, select,\n null {}\n other {Sebep: {reason}}\n}\n\nHesabına tekrar erişmek için şifreni sıfırlaman gerekiyor:\n\n{forgotUrl}\n\nŞifreni sıfırladıktan sonra tekrar giriş yapabilirsin.\n\nBunun bir hata olduğunu düşünüyorsan, lütfen destek ekibimizle iletişime geç.\n\n– {product_name} Güvenlik Ekibi"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name} şifre değişikliğini onayla",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabının şifresini değiştirme isteği aldık.\n\nBu değişikliği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir ve iki faktörlü kimlik doğrulamayı etkinleştir.\n\n– {product_name} Ekibi"
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabının şifresini değiştirme isteği aldık.\n\nBu değişikliği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt, date, full} {expiresAt, time, short} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir ve iki faktörlü kimlik doğrulamayı etkinleştir.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "{product_name} şifreni sıfırla",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Видалення твого акаунта {product_name} скасовано",
|
||||
"body": "Привіт, {username},\n\nЗаплановане видалення твого акаунта {product_name} скасовано. Твій акаунт не буде видалено.\n\nЯкщо у тебе є запитання, напиши на {safety_email}.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Твій акаунт {product_name} буде видалено через неактивність",
|
||||
"body": "Привіт, {username},\n\nТвій акаунт {product_name} тривалий час був неактивним, тому його остаточне видалення заплановано на:\n\n{deletionDate, date, full} о {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nЯкщо хочеш зберегти акаунт, напиши на {safety_email} з цієї електронної адреси до цієї дати.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Видалення акаунта {product_name} заплановано",
|
||||
"body": "Привіт, {username},\n\nНа твій запит остаточне видалення акаунта {product_name} заплановано на:\n\n{deletionDate, date, full} о {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nДо того часу твій акаунт заблоковано. Якщо цей запит не від тебе або ти хочеш зберегти акаунт, напиши на {safety_email} з цієї електронної адреси до цієї дати.\n\n– Команда {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Твій акаунт {product_name} тимчасово деактивовано",
|
||||
"body": "Привіт, {username},\n\nМи тимчасово деактивували твій акаунт {product_name}, оскільки виявили підозрілу активність.\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nЩоб відновити доступ до акаунта, тобі потрібно скинути пароль:\n\n{forgotUrl}\n\nПісля скидання пароля ти зможеш знову увійти.\n\nЯкщо ти вважаєш, що це помилка, будь ласка, зв'яжися з нашою службою підтримки.\n\n– Команда безпеки {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Підтвердь зміну пароля для {product_name}",
|
||||
"body": "Привіт, {username},\n\nМи отримали запит на зміну пароля для твого акаунта {product_name}.\n\nЩоб підтвердити цю зміну, введи цей код у застосунку:\n\n{code}\n\nЦей код діє до {expiresAt}.\n\nЯкщо цей запит не від тебе, хтось може мати доступ до твого акаунта. Негайно зміни свій пароль та увімкни двофакторну автентифікацію.\n\n– Команда {product_name}"
|
||||
"body": "Привіт, {username},\n\nМи отримали запит на зміну пароля для твого акаунта {product_name}.\n\nЩоб підтвердити цю зміну, введи цей код у застосунку:\n\n{code}\n\nЦей код діє до {expiresAt, date, full} о {expiresAt, time, short}.\n\nЯкщо цей запит не від тебе, хтось може мати доступ до твого акаунта. Негайно зміни свій пароль та увімкни двофакторну автентифікацію.\n\n– Команда {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Скинь свій пароль для {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "Lịch xóa tài khoản {product_name} của bạn đã được hủy",
|
||||
"body": "Xin chào {username},\n\nLịch xóa tài khoản {product_name} của bạn đã được hủy. Tài khoản của bạn sẽ không bị xóa.\n\nNếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ {safety_email}.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tài khoản {product_name} của bạn sẽ bị xóa do không hoạt động",
|
||||
"body": "Xin chào {username},\n\nTài khoản {product_name} của bạn đã không hoạt động trong thời gian dài, vì vậy tài khoản được lên lịch xóa vĩnh viễn vào:\n\n{deletionDate, date, full} lúc {deletionDate, time, short}{reason, select, null {} other {\n\nLý do: {reason}}}\n\nNếu bạn muốn giữ tài khoản, hãy liên hệ {safety_email} từ địa chỉ email này trước ngày đó.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Tài khoản {product_name} của bạn đã được lên lịch xóa",
|
||||
"body": "Xin chào {username},\n\nTheo yêu cầu của bạn, tài khoản {product_name} của bạn được lên lịch xóa vĩnh viễn vào:\n\n{deletionDate, date, full} lúc {deletionDate, time, short}{reason, select, null {} other {\n\nLý do: {reason}}}\n\nTài khoản của bạn sẽ bị khóa cho đến lúc đó. Nếu bạn không yêu cầu điều này, hoặc bạn muốn giữ tài khoản, hãy liên hệ {safety_email} từ địa chỉ email này trước ngày đó.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tài khoản {product_name} của bạn đã bị tạm thời vô hiệu hóa",
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã tạm thời vô hiệu hóa tài khoản {product_name} của bạn vì phát hiện hoạt động đáng ngờ.\n\n{reason, select,\n null {}\n other {Lý do: {reason}}\n}\n\nĐể truy cập lại tài khoản, bạn cần đặt lại mật khẩu:\n\n{forgotUrl}\n\nSau khi đặt lại mật khẩu, bạn sẽ có thể đăng nhập lại.\n\nNếu bạn cho rằng đây là nhầm lẫn, vui lòng liên hệ đội ngũ hỗ trợ của chúng tôi.\n\n– Đội ngũ An toàn {product_name}"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Xác nhận thay đổi mật khẩu của bạn trên {product_name}",
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu thay đổi mật khẩu trên tài khoản {product_name} của bạn.\n\nĐể xác nhận thay đổi này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn lúc {expiresAt}.\n\nNếu bạn không yêu cầu thay đổi này, ai đó có thể đang có quyền truy cập vào tài khoản của bạn. Hãy đổi mật khẩu ngay lập tức và bật xác thực hai yếu tố.\n\n– Đội ngũ {product_name}"
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu thay đổi mật khẩu trên tài khoản {product_name} của bạn.\n\nĐể xác nhận thay đổi này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn vào {expiresAt, date, full} lúc {expiresAt, time, short}.\n\nNếu bạn không yêu cầu thay đổi này, ai đó có thể đang có quyền truy cập vào tài khoản của bạn. Hãy đổi mật khẩu ngay lập tức và bật xác thực hai yếu tố.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Đặt lại mật khẩu của bạn trên {product_name}",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "你的 {product_name} 账号已取消删除",
|
||||
"body": "你好 {username},\n\n你的 {product_name} 账号的计划删除已取消。你的账号不会被删除。\n\n如有任何疑问,请联系 {safety_email}。\n\n– {product_name} 团队"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "你的 {product_name} 账号将因长期未活动而被删除",
|
||||
"body": "你好 {username},\n\n你的 {product_name} 账号已长期未活动,因此将于以下时间永久删除:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n原因:{reason}}}\n\n如果你想保留账号,请在该日期前使用此邮箱地址联系 {safety_email}。\n\n– {product_name} 团队"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "你的 {product_name} 账号已安排删除",
|
||||
"body": "你好 {username},\n\n根据你的请求,你的 {product_name} 账号将于以下时间永久删除:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n原因:{reason}}}\n\n在此之前,你的账号将被锁定。如果你未请求此操作,或者想保留账号,请在该日期前使用此邮箱地址联系 {safety_email}。\n\n– {product_name} 团队"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "你的 {product_name} 账号已被暂时禁用",
|
||||
"body": "你好 {username},\n\n我们检测到你的 {product_name} 账号存在可疑活动,因此暂时禁用了你的账号。\n\n{reason, select,\n null {}\n other {原因:{reason}}\n}\n\n要重新访问你的账号,你需要重置密码:\n\n{forgotUrl}\n\n重置密码后,你将能够再次登录。\n\n如果你认为这是误判,请联系我们的支持团队。\n\n– {product_name} 安全团队"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "确认你的 {product_name} 密码更改",
|
||||
"body": "你好 {username},\n\n我们收到了更改你的 {product_name} 账号密码的请求。\n\n要确认此更改,请在应用中输入此验证码:\n\n{code}\n\n此验证码将于 {expiresAt} 失效。\n\n如果你未请求此操作,可能有人可以访问你的账号。请立即更改密码并启用双重认证。\n\n– {product_name} 团队"
|
||||
"body": "你好 {username},\n\n我们收到了更改你的 {product_name} 账号密码的请求。\n\n要确认此更改,请在应用中输入此验证码:\n\n{code}\n\n此验证码将于 {expiresAt, date, full} {expiresAt, time, short} 失效。\n\n如果你未请求此操作,可能有人可以访问你的账号。请立即更改密码并启用双重认证。\n\n– {product_name} 团队"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "重置你的 {product_name} 账号密码",
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "您的 {product_name} 帳號已取消刪除",
|
||||
"body": "哈囉 {username},\n\n您的 {product_name} 帳號的排定刪除已取消。您的帳號不會被刪除。\n\n如有任何疑問,請聯絡 {safety_email}。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "您的 {product_name} 帳號將因長期未使用而刪除",
|
||||
"body": "哈囉 {username},\n\n您的 {product_name} 帳號已長期未使用,因此已排定於以下時間永久刪除:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n原因:{reason}}}\n\n如果您想保留帳號,請在該日期前使用此電子郵件地址聯絡 {safety_email}。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "您的 {product_name} 帳號已排定刪除",
|
||||
"body": "哈囉 {username},\n\n依照您的要求,您的 {product_name} 帳號已排定於以下時間永久刪除:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n原因:{reason}}}\n\n在此之前,您的帳號將被鎖定。如果您沒有提出此要求,或想保留帳號,請在該日期前使用此電子郵件地址聯絡 {safety_email}。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "您的 {product_name} 帳號已暫時停用",
|
||||
"body": "哈囉 {username},\n\n我們偵測到您的 {product_name} 帳號有可疑活動,因此已暫時停用您的帳號。\n\n{reason, select,\n null {}\n other {原因:{reason}}\n}\n\n若要重新取得帳號存取權,您需要重設密碼:\n\n{forgotUrl}\n\n重設密碼後,您將可以再次登入。\n\n如果您認為這是誤判,請聯絡我們的支援團隊。\n\n– {product_name} 安全團隊"
|
||||
@@ -65,7 +77,7 @@ const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "確認您的 {product_name} 密碼變更",
|
||||
"body": "哈囉 {username},\n\n我們收到變更您 {product_name} 帳號密碼的請求。\n\n若要確認此變更,請在應用程式中輸入此驗證碼:\n\n{code}\n\n此驗證碼將於 {expiresAt} 失效。\n\n如果您沒有提出此要求,可能有人已取得您帳號的存取權。請立即變更您的密碼並啟用雙重驗證。\n\n– {product_name} 團隊"
|
||||
"body": "哈囉 {username},\n\n我們收到變更您 {product_name} 帳號密碼的請求。\n\n若要確認此變更,請在應用程式中輸入此驗證碼:\n\n{code}\n\n此驗證碼將於 {expiresAt, date, full} {expiresAt, time, short} 失效。\n\n如果您沒有提出此要求,可能有人已取得您帳號的存取權。請立即變更您的密碼並啟用雙重驗證。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "重設您的 {product_name} 密碼",
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"account_deletion_cancelled": {
|
||||
"subject": "تم إلغاء حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nتم إلغاء الحذف المجدول لحسابك في {product_name}. لن يتم حذف حسابك.\n\nإذا كانت لديك أي أسئلة، يرجى الاتصال بـ{safety_email}.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
|
||||
"body": "مرحبًا {username}،\n\nلم يكن حسابك في {product_name} نشطًا لفترة طويلة، لذا تمت جدولته للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nإذا كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "تمت جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nبناءً على طلبك، تمت جدولة حسابك في {product_name} للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nسيظل حسابك مقفلًا حتى ذلك الحين. إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "تم تعطيل حسابك في {product_name} مؤقتًا",
|
||||
"body": "مرحبًا {username}،\n\nلقد قمنا بتعطيل حسابك في {product_name} مؤقتًا لأننا اكتشفنا نشاطًا مشبوهًا.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nلاستعادة الوصول إلى حسابك، ستحتاج إلى إعادة تعيين كلمة المرور الخاصة بك:\n\n{forgotUrl}\n\nبعد إعادة تعيين كلمة المرور الخاصة بك، ستتمكن من تسجيل الدخول مرة أخرى.\n\nإذا كنت تعتقد أن هذا حدث عن طريق الخطأ، يرجى الاتصال بفريق الدعم لدينا.\n\n– فريق أمان {product_name}"
|
||||
@@ -61,7 +73,7 @@
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "إعادة تعيين كلمة المرور في {product_name}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user