mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
196
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c488906131 | ||
|
|
39c72f0fb0 | ||
|
|
3736d94d73 | ||
|
|
192cec689a | ||
|
|
e895c41bf0 | ||
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 | ||
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 | ||
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 | ||
|
|
7a91f128e9 | ||
|
|
963ffc5550 | ||
|
|
a90991612c | ||
|
|
50ad23b760 | ||
|
|
425dab983b | ||
|
|
a0825e77c4 | ||
|
|
88038a1d5b | ||
|
|
c2c0fdb445 | ||
|
|
dcd5f09d6a | ||
|
|
590b1f36fd | ||
|
|
168ac727f1 | ||
|
|
deb86dd92e | ||
|
|
7ccec4d3b8 | ||
|
|
2f38bcdf26 | ||
|
|
f2785941aa | ||
|
|
ea9f83a443 | ||
|
|
bd6ca7290e | ||
|
|
b85e975fb5 | ||
|
|
b6e504f68c | ||
|
|
5fde6eb484 | ||
|
|
b16989d567 | ||
|
|
c9754ac11a | ||
|
|
f34e4a5115 | ||
|
|
44b3615298 | ||
|
|
211e98307d | ||
|
|
18c303abf6 | ||
|
|
7021a58090 | ||
|
|
320725a587 | ||
|
|
8450edc072 | ||
|
|
a1e2bf2c8d | ||
|
|
82b2f4ec5e | ||
|
|
c92e5d03a7 | ||
|
|
91340c5c84 | ||
|
|
045dd5d027 | ||
|
|
a21b9c4659 | ||
|
|
4b1b869802 | ||
|
|
1ab7e7dfcc | ||
|
|
31c53d2dff | ||
|
|
412a1ae79d | ||
|
|
0b2306ec3d | ||
|
|
242ed3a934 | ||
|
|
70e1ce682a | ||
|
|
7601bf98ee | ||
|
|
c7ec2a0f58 | ||
|
|
6a5e0056a8 | ||
|
|
78d105b46e | ||
|
|
c68d62b8a0 | ||
|
|
df58020f4c | ||
|
|
f052ce05aa | ||
|
|
eedfd9275f | ||
|
|
416af4bec4 | ||
|
|
108d282ddd | ||
|
|
a6103244b0 | ||
|
|
38935c83c5 | ||
|
|
c157ab5752 | ||
|
|
574a93257c | ||
|
|
ba7d8781cf | ||
|
|
3256af8d92 | ||
|
|
86043212f2 | ||
|
|
5d85e88532 | ||
|
|
e2abfd476a | ||
|
|
487febac8e | ||
|
|
a3454e8245 | ||
|
|
bf7567b768 | ||
|
|
ac3450ab32 | ||
|
|
5d034becb8 | ||
|
|
f9397d0db9 | ||
|
|
9005139dc8 | ||
|
|
d93604afa2 | ||
|
|
c4f0b2ece0 | ||
|
|
98a42f612b | ||
|
|
cc75e1318d | ||
|
|
9027cbdf3e | ||
|
|
2119e10ed5 | ||
|
|
87f3eb3c81 | ||
|
|
f9bb8bd585 | ||
|
|
bc47a724af | ||
|
|
f32356801d | ||
|
|
efd677f32b | ||
|
|
3cec27ba57 | ||
|
|
1f810ba04d | ||
|
|
522cf08e61 | ||
|
|
025c01ab13 | ||
|
|
dc41b53d60 | ||
|
|
3b552e00ef | ||
|
|
56e04e7b53 | ||
|
|
deac653a9e | ||
|
|
ed9528834d | ||
|
|
4cecbf1f43 | ||
|
|
b019f4a91f | ||
|
|
34b6ecfbd2 | ||
|
|
4ef9c4c65b | ||
|
|
3276039e41 | ||
|
|
03d1354562 | ||
|
|
964845d7a7 | ||
|
|
3bc5dd8e0f | ||
|
|
17292fd6a5 | ||
|
|
f753659899 | ||
|
|
7412ec3395 | ||
|
|
570c8776c4 | ||
|
|
910db6734b | ||
|
|
9e614026d7 | ||
|
|
b38e7c6433 | ||
|
|
83c8e91955 | ||
|
|
0532dd0440 | ||
|
|
a08615e306 | ||
|
|
f4c5fee17e | ||
|
|
c5aaf65a10 | ||
|
|
951e39da3d | ||
|
|
b693d84d2b | ||
|
|
9bbf6c513b | ||
|
|
50cec92738 | ||
|
|
c212d315f4 | ||
|
|
1861432a53 | ||
|
|
d0c6146429 | ||
|
|
550e6b05a1 | ||
|
|
5b6949170f | ||
|
|
f32bc37794 | ||
|
|
8ee2279b4b | ||
|
|
6339c3b8ad | ||
|
|
7c9274847f | ||
|
|
5d1dddc093 | ||
|
|
04481d7235 | ||
|
|
a3d6cf37cb | ||
|
|
ed10f9d323 | ||
|
|
4b278a0da8 | ||
|
|
1281045648 | ||
|
|
69c42cff90 | ||
|
|
7d56481aba | ||
|
|
91a2604e9e | ||
|
|
a9dc74a520 | ||
|
|
a9cc04d277 | ||
|
|
8cb097f954 | ||
|
|
78f783f0c4 | ||
|
|
d14998ff69 | ||
|
|
ca7ddd9272 | ||
|
|
84dcf6b8a8 | ||
|
|
a59b80ce11 |
@@ -1,14 +1,14 @@
|
||||
FROM chrislusf/seaweedfs:4.31 AS seaweedfs
|
||||
FROM chrislusf/seaweedfs:4.47 AS seaweedfs
|
||||
|
||||
FROM erlang:28.5.0.1
|
||||
FROM erlang:28.5.0.6
|
||||
|
||||
ARG USERNAME=vscode
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=24
|
||||
ARG ELP_VERSION=2026-02-27
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
ARG WASM_BINDGEN_VERSION=0.2.123
|
||||
ARG NODE_MAJOR=26
|
||||
ARG ELP_VERSION=2026-08-10
|
||||
ARG PNPM_VERSION=11.27.0
|
||||
ARG WASM_BINDGEN_VERSION=0.2.128
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -131,7 +131,8 @@ RUN apt-get update \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
&& npm install -g "pnpm@${PNPM_VERSION}" \
|
||||
&& pnpm --version
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
@@ -167,7 +168,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
|
||||
arm64) ELP_ARCH="aarch64" ;; \
|
||||
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
||||
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.5.tar.gz" -o /tmp/elp.tgz \
|
||||
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
|
||||
&& chmod +x /usr/local/bin/elp \
|
||||
&& rm /tmp/elp.tgz
|
||||
@@ -194,7 +195,4 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs
|
||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
|
||||
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
|
||||
|
||||
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
|
||||
&& pnpm --version
|
||||
|
||||
WORKDIR /workspaces/fluxer
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"erlang.includePaths": ["."],
|
||||
"search.exclude": {
|
||||
"**/_build/default/lib/fluxer_gateway": true
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
init: true
|
||||
environment:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
||||
pnpm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
||||
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
@@ -292,13 +292,13 @@ services:
|
||||
start_period: 5s
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.7-alpine
|
||||
image: valkey/valkey:9.1.2-alpine
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
ports:
|
||||
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||
|
||||
nats:
|
||||
image: nats:2.14.2-alpine
|
||||
image: nats:2.14.7-alpine
|
||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
@@ -321,9 +321,10 @@ services:
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
image: getmeili/meilisearch:v1.53
|
||||
environment:
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_UPGRADE_DB: "true"
|
||||
MEILI_MASTER_KEY: fluxer-dev-meilisearch
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
@@ -337,7 +338,7 @@ services:
|
||||
start_period: 5s
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30
|
||||
image: axllent/mailpit:v1.31
|
||||
environment:
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_MAX_MESSAGES: 5000
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
/fluxer_docs/.astro/
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
@@ -28,9 +28,9 @@ f:media_proxy:
|
||||
f:messages:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_messages/**/*
|
||||
f:recon:
|
||||
f:push:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_recon/**/*
|
||||
- any-glob-to-any-file: fluxer_push/**/*
|
||||
f:snowflakes:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_snowflakes/**/*
|
||||
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
@@ -101,19 +101,19 @@ jobs:
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
@@ -141,15 +141,15 @@ jobs:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
toolchain: "1.98.1"
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -43,13 +43,13 @@ jobs:
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: set variables
|
||||
id: vars
|
||||
run: >-
|
||||
@@ -71,20 +71,19 @@ jobs:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL: ""
|
||||
BUNDLE_LOCAL_ASSETS: "true"
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
@@ -131,19 +130,19 @@ jobs:
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||
with:
|
||||
context: .
|
||||
file: fluxer_app_proxy/Dockerfile
|
||||
@@ -155,7 +154,6 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
@@ -173,15 +171,15 @@ jobs:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
toolchain: "1.98.1"
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -43,13 +43,13 @@ jobs:
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: set variables
|
||||
id: vars
|
||||
run: >-
|
||||
@@ -67,18 +67,18 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
@@ -131,13 +131,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
@@ -178,19 +178,19 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||
with:
|
||||
context: .
|
||||
file: fluxer_app_proxy/Dockerfile
|
||||
@@ -219,15 +219,15 @@ jobs:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
toolchain: "1.98.1"
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -11,11 +11,6 @@ on:
|
||||
- stable
|
||||
- canary
|
||||
default: stable
|
||||
test_build:
|
||||
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
build_version:
|
||||
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
||||
required: false
|
||||
@@ -32,13 +27,12 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
|
||||
group: desktop-${{ inputs.channel }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
||||
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
@@ -53,19 +47,17 @@ jobs:
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
build_channel: ${{ steps.meta.outputs.build_channel }}
|
||||
test_build: ${{ steps.meta.outputs.test_build }}
|
||||
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
|
||||
source_sha: ${{ steps.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
@@ -85,7 +77,6 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_metadata
|
||||
--channel "${{ inputs.channel }}"
|
||||
--test-build "${{ inputs.test_build }}"
|
||||
|
||||
matrix:
|
||||
name: Resolve build matrix
|
||||
@@ -98,12 +89,12 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Build platform matrix
|
||||
id: set-matrix
|
||||
@@ -113,7 +104,7 @@ jobs:
|
||||
--skip-targets "${{ inputs.skip_targets }}"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs:
|
||||
- meta
|
||||
- matrix
|
||||
@@ -137,41 +128,34 @@ jobs:
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||
steps:
|
||||
- name: Checkout CI helpers
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: _ci
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: source
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Set up Python (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Ensure python3 command (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -196,14 +180,14 @@ jobs:
|
||||
--step set_workdir_unix
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
|
||||
- name: Set up pnpm via corepack
|
||||
- name: Set up pnpm
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step setup_pnpm_corepack
|
||||
--step setup_pnpm
|
||||
|
||||
- name: Resolve pnpm store path (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -247,9 +231,9 @@ jobs:
|
||||
|
||||
- name: Set up Rust toolchain (Unix)
|
||||
if: matrix.platform != 'windows'
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
|
||||
- name: Install MSVC ARM64 build tools
|
||||
@@ -260,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Set up MSVC env (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
uses: TheMrMilchmann/setup-msvc-dev@79dac248aac9d0059f86eae9d8b5bfab4e95e97c
|
||||
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9
|
||||
with:
|
||||
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
|
||||
|
||||
@@ -302,9 +286,9 @@ jobs:
|
||||
|
||||
- name: Set up .NET SDK (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Install Velopack CLI
|
||||
if: matrix.platform == 'windows'
|
||||
@@ -363,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Azure login for Artifact Signing
|
||||
if: matrix.platform == 'windows'
|
||||
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -477,6 +461,12 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_artifacts_unix
|
||||
|
||||
- name: Build AppImage update feed (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_appimage_update_feed
|
||||
|
||||
- name: Normalize updater YAML (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: >-
|
||||
@@ -495,13 +485,23 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step generate_checksums_windows
|
||||
|
||||
- name: Upload artifacts to S3 handoff
|
||||
- name: Stage build artifacts
|
||||
id: handoff
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_handoff
|
||||
--step stage_handoff
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ${{ steps.handoff.outputs.artifact_name }}
|
||||
path: upload_staging
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
upload:
|
||||
name: Upload to S3
|
||||
name: Assemble desktop release assets
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
@@ -520,34 +520,26 @@ jobs:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download S3 handoff artifacts
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_handoff
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
|
||||
|
||||
- name: Build S3 payload layout (+ manifest.json)
|
||||
- name: Build payload layout (+ manifest.json)
|
||||
env:
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
@@ -556,42 +548,27 @@ jobs:
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
- name: Upload GitHub release assets
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: fluxer-desktop-release-assets
|
||||
path: release_assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_summary
|
||||
|
||||
- name: Cleanup S3 handoff
|
||||
if: ${{ success() }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step cleanup_handoff
|
||||
|
||||
publish_release:
|
||||
name: Publish GitHub desktop release
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- upload
|
||||
@@ -603,28 +580,22 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: fluxer-desktop-release-assets
|
||||
path: release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
@@ -656,15 +627,3 @@ jobs:
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build recon
|
||||
name: build push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-recon
|
||||
dockerfile: fluxer_recon/Dockerfile
|
||||
image: fluxer-push
|
||||
dockerfile: fluxer_push/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
@@ -19,22 +19,22 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout fluxer
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -35,24 +35,24 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
token: ${{ steps.create-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "26"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout Weblate branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
token: ${{ steps.create-token.outputs.token }}
|
||||
ref: ${{ env.WEBLATE_BRANCH }}
|
||||
@@ -48,17 +48,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "26"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Label pull request
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13
|
||||
with:
|
||||
repo-token: ${{ steps.create-token.outputs.token }}
|
||||
configuration-path: .github/labeller.yaml
|
||||
|
||||
@@ -56,15 +56,15 @@ jobs:
|
||||
contents: write
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
toolchain: "1.98.1"
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -28,12 +28,12 @@ jobs:
|
||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
@@ -55,16 +55,16 @@ jobs:
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -83,12 +83,12 @@ jobs:
|
||||
PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
@@ -105,12 +105,12 @@ jobs:
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -123,12 +123,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -142,12 +146,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -156,21 +164,21 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -185,11 +193,14 @@ jobs:
|
||||
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --version 0.19.6 --locked
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
|
||||
- name: Check Rust dependencies
|
||||
run: cargo deny --locked check -D warnings
|
||||
|
||||
- name: Check libfluxwebp dependencies
|
||||
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
|
||||
|
||||
- name: Check desktop native dependencies
|
||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||
|
||||
@@ -242,6 +253,9 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check formatting (libfluxwebp)
|
||||
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
|
||||
|
||||
- name: Check formatting (desktop native workspaces)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
||||
|
||||
@@ -273,12 +287,12 @@ jobs:
|
||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Cache cargo (gateway NIFs)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
||||
@@ -294,7 +308,7 @@ jobs:
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
@@ -305,7 +319,7 @@ jobs:
|
||||
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
|
||||
with:
|
||||
otp-version: '28'
|
||||
rebar3-version: '3.24.0'
|
||||
rebar3-version: '3.27.0'
|
||||
|
||||
- name: Restore rebar3 dependencies
|
||||
id: rebar3-cache
|
||||
@@ -317,10 +331,13 @@ jobs:
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
restore-keys: |
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
|
||||
|
||||
- name: Drop restored gateway build output
|
||||
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
@@ -348,7 +365,7 @@ jobs:
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
|
||||
knip:
|
||||
@@ -358,12 +375,12 @@ jobs:
|
||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
@@ -372,7 +389,7 @@ jobs:
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
@@ -380,12 +397,12 @@ jobs:
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -398,12 +415,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -417,12 +438,16 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
@@ -431,15 +456,15 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -456,15 +481,15 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -490,15 +515,15 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -515,12 +540,12 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install font tooling
|
||||
run: python3 -m pip install -r tools/fonts/requirements.txt
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.pnpm-store/
|
||||
/.vscode/
|
||||
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
|
||||
Generated
+860
-867
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -7,15 +7,14 @@ members = [
|
||||
"fluxer_gifs",
|
||||
"fluxer_svc",
|
||||
"fluxer_messages",
|
||||
"fluxer_push",
|
||||
"fluxer_snowflakes",
|
||||
"tools/ci",
|
||||
"tools/content/update-frozen-snapshot",
|
||||
"tools/dev",
|
||||
"tools/i18n_auto",
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
"fluxer_recon",
|
||||
]
|
||||
exclude = [
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
|
||||
@@ -6,18 +6,173 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="https://fluxer.app/download">
|
||||
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
|
||||
<a href="https://docs.fluxer.app">
|
||||
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
|
||||
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
|
||||
</p>
|
||||
|
||||
# Fluxer
|
||||
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
|
||||
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||
|
||||
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
|
||||
|
||||
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
|
||||
|
||||
### Flatpak
|
||||
|
||||
Stable is on [Flathub][flathub], the easiest route on most desktops:
|
||||
|
||||
```sh
|
||||
flatpak install flathub app.fluxer.Fluxer
|
||||
```
|
||||
|
||||
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
```
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
For canary, use the canary entry file and package.
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
|
||||
sudo apt update && sudo apt install fluxer-canary
|
||||
```
|
||||
|
||||
A `.deb` installed from a download only updates once its channel's entry is added.
|
||||
|
||||
### Fedora and RHEL
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
For canary, use the canary entry file and package.
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
|
||||
sudo dnf install fluxer-canary
|
||||
```
|
||||
|
||||
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
The repository is signed, so pacman needs the key once:
|
||||
|
||||
```sh
|
||||
sudo pacman-key --init
|
||||
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||
```
|
||||
|
||||
`--lsign-key` is what makes pacman trust it. Then add the repository:
|
||||
|
||||
```sh
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
|
||||
[fluxer]
|
||||
SigLevel = Required TrustedOnly
|
||||
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||
REPO
|
||||
sudo pacman -Syu fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
|
||||
|
||||
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
|
||||
|
||||
## Other ways to run it
|
||||
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
|
||||
- [Host your own instance][docs-selfhost] from this repository.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation home][docs]
|
||||
- [Downloads][docs-downloads]
|
||||
- [Self-hosting][docs-selfhost]
|
||||
|
||||
## License
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
|
||||
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
|
||||
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
|
||||
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
|
||||
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
|
||||
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
|
||||
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
|
||||
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
|
||||
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
|
||||
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
|
||||
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
[docs]: https://docs.fluxer.app
|
||||
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
|
||||
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
|
||||
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
|
||||
|
||||
+3
-2
@@ -48,7 +48,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noImportantStyles": "off",
|
||||
@@ -83,6 +83,7 @@
|
||||
}
|
||||
},
|
||||
"useConst": "error",
|
||||
"noDescendingSpecificity": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"noRestrictedImports": {
|
||||
@@ -98,7 +99,7 @@
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"useAriaPropsForRole": "error",
|
||||
"useValidAriaRole": "error",
|
||||
"useValidAriaValues": "error",
|
||||
|
||||
Vendored
+1
-6
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
|
||||
FLUXER_S3_FORCE_PATH_STYLE=true
|
||||
FLUXER_S3_BUCKET_CDN=fluxer
|
||||
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
FLUXER_S3_BUCKET_STATIC=fluxer-static
|
||||
@@ -65,11 +64,6 @@ 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_RECON_MODE=observing
|
||||
FLUXER_RECON_EXPECTED_ROOMS=64
|
||||
FLUXER_RECON_WARMUP_SECONDS=15
|
||||
FLUXER_RECON_MAX_HOT_ROOMS=8
|
||||
|
||||
FLUXER_API_PORT=8080
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
||||
FLUXER_API_WORKER_MODE=all_lanes
|
||||
@@ -135,6 +129,7 @@ PUBLIC_RELEASE_CHANNEL=canary
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
||||
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
|
||||
|
||||
@@ -79,34 +79,42 @@ deny = [
|
||||
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
|
||||
]
|
||||
skip = [
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
|
||||
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
|
||||
{ crate = "[email protected].6", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected].4", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected].8", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected].5", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "s[email protected]0", reason = "transitive dependency requires the older socket API" },
|
||||
{ crate = "s[email protected].0", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
|
||||
]
|
||||
skip-tree = []
|
||||
|
||||
|
||||
+117
-185
@@ -1,108 +1,64 @@
|
||||
# Every variable docker-compose.yml reads is named here, uncommented when it has
|
||||
# no default and commented with its default when it has one. A name absent from
|
||||
# this file reaches a service only through a Compose override. Compose expands
|
||||
# top to bottom, so a line using ${...} must sit below every name it reads.
|
||||
# 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.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The lines above are the address browsers use, and every advertised endpoint
|
||||
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
|
||||
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
|
||||
# needs the matching one set as well. Complete recipes sit beside them.
|
||||
# The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
|
||||
# which host ports Fluxer binds.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
# Point DNS at this host.
|
||||
#
|
||||
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
|
||||
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
|
||||
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
|
||||
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
|
||||
# proxy serves, not this local port.
|
||||
# By default Fluxer binds 80 and 443 and gets its own certificate. Point DNS here.
|
||||
# Behind your own reverse proxy, uncomment this instead: Fluxer then serves plain
|
||||
# HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
|
||||
# address, not this one.
|
||||
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
|
||||
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
|
||||
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
|
||||
# is on another machine, and firewall the port to that machine.
|
||||
# Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
|
||||
# another machine, and firewall it to that machine.
|
||||
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
||||
|
||||
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
|
||||
# this to the real client address, so IP bans, rate limits and abuse detection
|
||||
# see the caller rather than the proxy. The default covers proxies on private or
|
||||
# loopback addresses, which is every same-host setup. Set it to your proxy's
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
# Which hops may set X-Forwarded-For. The default covers private and loopback
|
||||
# addresses. Set your proxy's address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
# The origin browsers see, no trailing slash. Set it when browsers reach the
|
||||
# instance on a host FLUXER_DOMAIN does not name, and it wins over the three
|
||||
# values above. Scheme, host and optional port only. It does not move the
|
||||
# published ports.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
# The address the edge listens on inside its container. Both proxy overlays set
|
||||
# this themselves, so a value here is ignored under either. Include the scheme.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
|
||||
# unset, so an existing .env keeps the listener it already had.
|
||||
# The old name for the line above, read only when it is unset.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# Host side of the edge's publishes, and the only names that decide which host
|
||||
# ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
# Host ports. Container 80 handles the redirect and the certificate challenge,
|
||||
# container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
|
||||
# HTTP/3 needs both. Both accept a bind address. Give them different host ports.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
# HTTPS on 8443. Host 80 stays published for the certificate challenge, which
|
||||
# only ever arrives on public 80 or 443. Serve your own certificate if nothing
|
||||
# forwards those.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
# Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
|
||||
# 443 publish on loopback.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
# A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
|
||||
# leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
|
||||
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
|
||||
|
||||
FLUXER_REGISTRY_OWNER=fluxerapp
|
||||
@@ -111,11 +67,8 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
# 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.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
@@ -125,19 +78,15 @@ 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 object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# 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_DOWNLOADS=fluxer-downloads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
# The other bundled services, pointed elsewhere. Removing a service from the
|
||||
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
|
||||
#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
|
||||
@@ -145,24 +94,27 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
# Voice off. The livekit service still runs until an override removes it.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
# Optional systems, each off unless configured.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
# Outside lookups, off unless turned on. The Tor exit list comes from
|
||||
# onionoo.torproject.org and the breached password check asks
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_TOR_EXIT_LIST_ENABLED=true
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
|
||||
|
||||
# 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
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
# How much the services write. trace, debug, info, warn, error or fatal.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
@@ -177,32 +129,49 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
# The token every service sends to NATS. The bundled NATS needs none, so this
|
||||
# stays empty unless an override points at an external one.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
|
||||
# only if that mailbox does not exist.
|
||||
# Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
|
||||
# exist.
|
||||
#[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
||||
# invalidates every passkey already registered against the old value.
|
||||
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
|
||||
# Changing the RP ID invalidates every passkey registered against the old value.
|
||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
|
||||
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
||||
# sources with spaces or commas. Every one of them is empty by default, and the
|
||||
# three carrying a value below are illustrations, not defaults.
|
||||
# Notification jobs the push container holds at once, 1 to 1000000.
|
||||
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
|
||||
# Provider requests the push container sends at once, 1 to 65536.
|
||||
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
|
||||
|
||||
|
||||
# Optional media policies, both off by default. See the operator docs.
|
||||
#
|
||||
# CORS limits which web origins may read media. A request with no Origin is
|
||||
# always served. Add https://web.fluxer.app if people use the hosted client.
|
||||
#
|
||||
# Signatures make an attachment read need a signed URL, so a copied link stops
|
||||
# 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.
|
||||
#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=
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
|
||||
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set one
|
||||
# only when a browser must reach an origin the defaults do not cover. Separate
|
||||
# several with spaces or commas. The three values below are illustrations.
|
||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
@@ -214,39 +183,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
||||
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
||||
|
||||
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
|
||||
# directive off the header.
|
||||
# One report-uri for CSP violation reports. Empty leaves the directive off.
|
||||
#FLUXER_CSP_REPORT_URI=
|
||||
|
||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
||||
# provider URL cannot be used to reach internal services. Turn it on only when the
|
||||
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
|
||||
# identity provider, and only when you trust everyone who can configure SSO.
|
||||
# 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.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
|
||||
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
|
||||
# These reach both LiveKit and the api. Change them together.
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
# The URL browsers use for voice signalling. Built from the public origin plus
|
||||
# /livekit. Set it only when LiveKit is served from another host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
# forward the same numbers.
|
||||
# Media ports. LiveKit advertises these, so forward the same numbers.
|
||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
|
||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
|
||||
# server to keep the lookup and leave Google out of it.
|
||||
# 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.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||
@@ -273,11 +232,9 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from services whose death takes the instance down. They reserve
|
||||
# nothing. Lower the limits on a smaller host.
|
||||
# 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
|
||||
# whose death takes the instance down. Lower the limits on a smaller host.
|
||||
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
@@ -294,6 +251,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
|
||||
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
|
||||
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
|
||||
#FLUXER_PUSH_MEMORY_LIMIT=256mb
|
||||
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
|
||||
@@ -308,80 +266,54 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
# 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
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
# Any value set here must stay well below the container limit above: a heap ceiling
|
||||
# above the container limit makes the kernel OOM-kill the container (exit 137, no
|
||||
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
|
||||
# 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.
|
||||
#FLUXER_API_NODE_HEAP_MB=1792
|
||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||
|
||||
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
|
||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
||||
# pool sizes used by the api, worker and shards.
|
||||
# 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
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
||||
# and nothing else stores the first of the two. It therefore runs with an
|
||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
||||
# you have moved that durable state elsewhere.
|
||||
# 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
|
||||
|
||||
# The gateway derives its BEAM scheduler count from the container CPU quota,
|
||||
# clamped to this range. The floor matters: a single scheduler lets one blocking
|
||||
# operation stall every websocket on the node. The ceiling stops a large host
|
||||
# from starting far more schedulers than the container can actually use.
|
||||
# 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
|
||||
|
||||
# In-flight request ceiling for the services Compose forwards it to: the users
|
||||
# and messages routers and their shards. Leave it unset and each service uses its
|
||||
# built-in default. Set it and the one value replaces that default on all of
|
||||
# them, so size it for the busiest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
# 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
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
||||
# connection pooler such as PgBouncer in front of Postgres. One setting governs
|
||||
# every service. The bundled compose talks to Postgres directly, where naming is
|
||||
# a win and the default is correct.
|
||||
# Named prepared statements need a session that outlives the transaction, so set
|
||||
# this to false behind a transaction-pooling connection pooler. The bundled
|
||||
# compose talks to Postgres directly, where the default is correct.
|
||||
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
||||
|
||||
# The api bounds how long a client may take to send a request. The header timeout
|
||||
# covers the request line and headers only, while the request timeout covers the
|
||||
# whole exchange, so a slow uploader is bounded by the second value and not by
|
||||
# the first. Raise both if you front large uploads or serve clients on high
|
||||
# latency links. The header timeout is clamped down to the request timeout, so
|
||||
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
|
||||
# How long a client may take to send a request. The header timeout covers the
|
||||
# request line and headers, the request timeout the whole exchange, and the first
|
||||
# is clamped down to the second. Milliseconds, 1000 to 3600000.
|
||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
|
||||
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_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
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}
|
||||
@@ -97,6 +98,7 @@ x-fluxer-env: &fluxer-env
|
||||
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}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ${FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64:-}
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
|
||||
|
||||
@@ -122,7 +124,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
|
||||
services:
|
||||
edge:
|
||||
image: caddy:2.10-alpine
|
||||
image: caddy:2.11-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -186,7 +188,7 @@ services:
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
@@ -200,7 +202,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1-alpine
|
||||
image: valkey/valkey:9.1-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -236,7 +238,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
image: getmeili/meilisearch:v1.53
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -246,6 +248,7 @@ services:
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_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}
|
||||
volumes:
|
||||
@@ -257,7 +260,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -266,7 +269,7 @@ services:
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data"]
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
healthcheck:
|
||||
@@ -277,7 +280,7 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
seaweedfs-init:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -291,14 +294,13 @@ services:
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
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
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -413,7 +415,6 @@ services:
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
|
||||
FLUXER_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)}"]
|
||||
@@ -473,11 +474,38 @@ services:
|
||||
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_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"
|
||||
depends_on:
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
push:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
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:-}"
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
start_interval: 1s
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
api: {condition: service_healthy}
|
||||
|
||||
static-proxy:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
|
||||
|
||||
+13
-13
@@ -9,32 +9,32 @@ build = "build.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde"] }
|
||||
cookie = "0.18.1"
|
||||
cookie = "0.18.2"
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
hmac = "0.13.0"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
rand = "0.10"
|
||||
regress = "0.11"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
regress = "0.12"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
sha2 = "0.11.0"
|
||||
time = { version = "0.3.47", features = ["formatting", "parsing"] }
|
||||
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
||||
time = { version = "0.3.55", features = ["formatting", "parsing"] }
|
||||
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
|
||||
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
url = "2.5"
|
||||
urlencoding = "2.1.3"
|
||||
progenitor-client = { version = "0.14.0", default-features = false }
|
||||
progenitor-client = { version = "0.15.0", default-features = false }
|
||||
|
||||
[build-dependencies]
|
||||
openapiv3 = "2.2.0"
|
||||
prettyplease = "0.2"
|
||||
progenitor = { version = "0.14.0", default-features = false }
|
||||
prettyplease = "0.3"
|
||||
progenitor = { version = "0.15.0", default-features = false }
|
||||
serde_json = "1"
|
||||
sha2 = "0.11.0"
|
||||
syn = "2"
|
||||
syn = "3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
FROM rust:1-bookworm AS builder
|
||||
FROM rust:1-trixie AS builder
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
ARG TARGETARCH
|
||||
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
||||
&& npm install -g pnpm@10.29.3 \
|
||||
&& npm install -g pnpm@11.27.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
|
||||
@@ -57,7 +57,7 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
|
||||
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
|
||||
&& echo "Latin-core fonts bundled successfully"
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
FROM debian:trixie-slim AS runtime
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
ARG SOURCE_SHA=""
|
||||
|
||||
+313
-2
@@ -38,6 +38,8 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
let mut spec: openapiv3::OpenAPI =
|
||||
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
relax_guild_audit_log_schemas(&mut spec);
|
||||
relax_progenitor_schema_strictness(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
@@ -52,9 +54,9 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
.generate_tokens(&spec)
|
||||
.expect("failed to generate admin API client");
|
||||
|
||||
let content = prettyplease::unparse(
|
||||
let content = relax_required_nullable_fields(&prettyplease::unparse(
|
||||
&syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
|
||||
);
|
||||
));
|
||||
|
||||
let output_path = out_dir.join("admin_api_generated.rs");
|
||||
fs::write(&output_path, content).expect("failed to write generated API code");
|
||||
@@ -141,6 +143,315 @@ fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
|
||||
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
|
||||
entry.additional_properties = None;
|
||||
let openapiv3::ReferenceOr::Item(options) = entry
|
||||
.properties
|
||||
.get_mut("options")
|
||||
.expect("GuildAuditLogEntryResponse has no options property")
|
||||
else {
|
||||
panic!("GuildAuditLogEntryResponse options must be an inline schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
|
||||
else {
|
||||
panic!("GuildAuditLogEntryResponse options must be an object schema");
|
||||
};
|
||||
options.additional_properties = None;
|
||||
|
||||
let change = object_schema_mut(components, "AuditLogChangeSchema");
|
||||
change.additional_properties = None;
|
||||
for property in ["old_value", "new_value"] {
|
||||
change.properties.insert(
|
||||
property.to_string(),
|
||||
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
|
||||
schema_data: openapiv3::SchemaData::default(),
|
||||
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema_mut<'a>(
|
||||
components: &'a mut openapiv3::Components,
|
||||
name: &str,
|
||||
) -> &'a mut openapiv3::ObjectType {
|
||||
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
|
||||
panic!("missing inline {name} schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
|
||||
else {
|
||||
panic!("{name} must be an object schema");
|
||||
};
|
||||
object
|
||||
}
|
||||
|
||||
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
|
||||
|
||||
fn relax_required_nullable_fields(generated: &str) -> String {
|
||||
const PRESENCE_CHECK: &str =
|
||||
"#[serde(deserialize_with = \"::std::option::Option::deserialize\")]";
|
||||
generated
|
||||
.lines()
|
||||
.filter(|line| line.trim() != PRESENCE_CHECK)
|
||||
.flat_map(|line| [line, "\n"])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
|
||||
let registry = spec.components.clone().unwrap_or_default();
|
||||
|
||||
if let Some(components) = spec.components.as_mut() {
|
||||
for schema in components.schemas.values_mut() {
|
||||
relax_schema_reference(schema, ®istry);
|
||||
}
|
||||
for response in components.responses.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
for parameter in components.parameters.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
for request_body in components.request_bodies.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
}
|
||||
for header in components.headers.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||
relax_parameter_format(&mut header.format, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for path in spec.paths.paths.values_mut() {
|
||||
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||
continue;
|
||||
};
|
||||
for parameter in &mut path.parameters {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
for operation in [
|
||||
&mut path.get,
|
||||
&mut path.put,
|
||||
&mut path.post,
|
||||
&mut path.delete,
|
||||
&mut path.options,
|
||||
&mut path.head,
|
||||
&mut path.patch,
|
||||
&mut path.trace,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for parameter in &mut operation.parameters {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
|
||||
operation.request_body.as_mut()
|
||||
{
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
for response in operation
|
||||
.responses
|
||||
.responses
|
||||
.values_mut()
|
||||
.chain(operation.responses.default.iter_mut())
|
||||
{
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
|
||||
relax_content(&mut response.content, registry);
|
||||
for header in response.headers.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||
relax_parameter_format(&mut header.format, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
|
||||
for media_type in content.values_mut() {
|
||||
if let Some(schema) = media_type.schema.as_mut() {
|
||||
relax_schema_reference(schema, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
|
||||
let format = match parameter {
|
||||
openapiv3::Parameter::Query { parameter_data, .. }
|
||||
| openapiv3::Parameter::Header { parameter_data, .. }
|
||||
| openapiv3::Parameter::Path { parameter_data, .. }
|
||||
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
|
||||
};
|
||||
relax_parameter_format(format, registry);
|
||||
}
|
||||
|
||||
fn relax_parameter_format(
|
||||
format: &mut openapiv3::ParameterSchemaOrContent,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
match format {
|
||||
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
|
||||
relax_schema_reference(schema, registry)
|
||||
}
|
||||
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_schema_reference(
|
||||
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||
relax_schema(schema, registry);
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_boxed_schema_reference(
|
||||
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||
relax_schema(schema, registry);
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
|
||||
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
|
||||
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
|
||||
return;
|
||||
}
|
||||
match &mut schema.schema_kind {
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
|
||||
relax_additional_properties(&mut object.additional_properties, registry);
|
||||
for property in object.properties.values_mut() {
|
||||
relax_boxed_schema_reference(property, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
|
||||
if let Some(items) = array.items.as_mut() {
|
||||
relax_boxed_schema_reference(items, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Type(_) => {}
|
||||
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
|
||||
for subschema in subschemas {
|
||||
relax_schema_reference(subschema, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
|
||||
openapiv3::SchemaKind::Any(any) => {
|
||||
relax_additional_properties(&mut any.additional_properties, registry);
|
||||
for property in any.properties.values_mut() {
|
||||
relax_boxed_schema_reference(property, registry);
|
||||
}
|
||||
if let Some(items) = any.items.as_mut() {
|
||||
relax_boxed_schema_reference(items, registry);
|
||||
}
|
||||
for subschema in any
|
||||
.one_of
|
||||
.iter_mut()
|
||||
.chain(any.all_of.iter_mut())
|
||||
.chain(any.any_of.iter_mut())
|
||||
{
|
||||
relax_schema_reference(subschema, registry);
|
||||
}
|
||||
if let Some(not) = any.not.as_mut() {
|
||||
relax_schema_reference(not, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_additional_properties(
|
||||
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
match additional_properties {
|
||||
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
|
||||
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
|
||||
relax_schema_reference(schema, registry)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn flattens_objects_beside_scalars(
|
||||
schema_kind: &openapiv3::SchemaKind,
|
||||
registry: &openapiv3::Components,
|
||||
) -> bool {
|
||||
let subschemas = match schema_kind {
|
||||
openapiv3::SchemaKind::OneOf { one_of } => one_of,
|
||||
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
|
||||
_ => return false,
|
||||
};
|
||||
let mut objects = false;
|
||||
let mut scalars = false;
|
||||
for subschema in subschemas {
|
||||
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
|
||||
objects = true;
|
||||
} else {
|
||||
scalars = true;
|
||||
}
|
||||
}
|
||||
objects && scalars
|
||||
}
|
||||
|
||||
fn resolves_to_object(
|
||||
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||
registry: &openapiv3::Components,
|
||||
depth: usize,
|
||||
) -> bool {
|
||||
let Some(depth) = depth.checked_sub(1) else {
|
||||
return false;
|
||||
};
|
||||
let schema = match schema {
|
||||
openapiv3::ReferenceOr::Reference { reference } => {
|
||||
let Some(target) = reference
|
||||
.strip_prefix("#/components/schemas/")
|
||||
.and_then(|name| registry.schemas.get(name))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
return resolves_to_object(target, registry, depth);
|
||||
}
|
||||
openapiv3::ReferenceOr::Item(schema) => schema,
|
||||
};
|
||||
match &schema.schema_kind {
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
|
||||
openapiv3::SchemaKind::Type(_) => false,
|
||||
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
|
||||
.iter()
|
||||
.any(|subschema| resolves_to_object(subschema, registry, depth)),
|
||||
openapiv3::SchemaKind::Not { .. } => false,
|
||||
openapiv3::SchemaKind::Any(any) => {
|
||||
any.typ.as_deref() == Some("object")
|
||||
|| !any.properties.is_empty()
|
||||
|| any.additional_properties.is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
|
||||
+454
-171
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
|
||||
pub const REPORT_VIEW: &str = "report:view";
|
||||
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
||||
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
|
||||
pub const SYSTEM_HEAP_SNAPSHOT: &str = "system:heap_snapshot";
|
||||
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
|
||||
pub const USER_DELETE: &str = "user:delete";
|
||||
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
|
||||
@@ -192,7 +191,6 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
REPORT_VIEW,
|
||||
REPORT_VIEW_REPORTER_PII,
|
||||
SYSTEM_DM_SEND,
|
||||
SYSTEM_HEAP_SNAPSHOT,
|
||||
USER_CANCEL_BULK_MESSAGE_DELETION,
|
||||
USER_DELETE,
|
||||
USER_DISABLE_SUSPICIOUS,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct SearchAuditLogsParams {
|
||||
pub admin_user_id: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub target_type: Option<String>,
|
||||
pub access: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
pub limit: u32,
|
||||
@@ -21,6 +22,12 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &SearchAuditLogsParams,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
let access = params
|
||||
.access
|
||||
.as_deref()
|
||||
.map(audit_access)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
@@ -46,6 +53,7 @@ impl AdminApiClient {
|
||||
params.target_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
("target_id", params.target_id.as_deref().unwrap_or_default()),
|
||||
("access", access.as_deref().unwrap_or_default()),
|
||||
("sort_by", sort_by.as_deref().unwrap_or_default()),
|
||||
("sort_order", sort_order.as_deref().unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
@@ -55,6 +63,11 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
|
||||
generated_types::ListAdminAuditLogsAccess::try_from(value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
@@ -83,6 +96,13 @@ mod tests {
|
||||
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_known_access_filters() {
|
||||
assert_eq!(audit_access("read").unwrap().to_string(), "read");
|
||||
assert_eq!(audit_access("write").unwrap().to_string(), "write");
|
||||
assert!(audit_access("all").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_lossy_audit_totals() {
|
||||
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||
@@ -99,6 +119,7 @@ mod tests {
|
||||
"admin_user_id": "234567890123456789",
|
||||
"admin_user": null,
|
||||
"action": "USER_UPDATE",
|
||||
"access": "write",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"target_user": null,
|
||||
@@ -118,6 +139,26 @@ mod tests {
|
||||
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
|
||||
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_audit_entries_from_an_api_without_access() {
|
||||
let json = serde_json::json!({
|
||||
"logs": [{
|
||||
"log_id": "123456789012345678",
|
||||
"admin_user_id": "234567890123456789",
|
||||
"action": "USER_UPDATE",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": null,
|
||||
"metadata": {},
|
||||
"created_at": "2026-09-11T12:00:00.000Z"
|
||||
}],
|
||||
"total": 1
|
||||
});
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(response.logs[0].access, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ impl AdminApiClient {
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
add_flags: user_flags(add_flags)?,
|
||||
remove_flags: user_flags(remove_flags)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
@@ -112,11 +112,13 @@ fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::UserFlags::from)
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -11,6 +12,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/instance/config", None).await
|
||||
}
|
||||
|
||||
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
|
||||
self.get("/.well-known/fluxer", None).await
|
||||
}
|
||||
|
||||
pub async fn update_instance_config(
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
|
||||
#[serde(default)]
|
||||
pub admin_user: Option<AuditLogUserSummary>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub access: Option<String>,
|
||||
pub target_id: String,
|
||||
pub target_type: String,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::{InstanceConfigResponse, PremiumMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const BILLING_MAX_CURRENCIES: usize = 64;
|
||||
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
|
||||
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
|
||||
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
|
||||
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
|
||||
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
|
||||
pub const TRI_STATE_DEFAULT: &str = "default";
|
||||
pub const TRI_STATE_ON: &str = "on";
|
||||
pub const TRI_STATE_OFF: &str = "off";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BillingCatalogMode {
|
||||
#[default]
|
||||
Env,
|
||||
Operator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct BillingPriceSet {
|
||||
pub monthly: Option<String>,
|
||||
pub yearly: Option<String>,
|
||||
pub gift_1_month: Option<String>,
|
||||
pub gift_1_year: Option<String>,
|
||||
}
|
||||
|
||||
impl BillingPriceSet {
|
||||
pub fn has_recurring_pair(&self) -> bool {
|
||||
self.monthly.is_some() && self.yearly.is_some()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.monthly.is_none()
|
||||
&& self.yearly.is_none()
|
||||
&& self.gift_1_month.is_none()
|
||||
&& self.gift_1_year.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceBillingResponse {
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub effective_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_secret_key_set: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_webhook_secret_set: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_secret_key_stored: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_webhook_secret_stored: bool,
|
||||
pub default_currency: Option<String>,
|
||||
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
|
||||
pub country_currencies: Option<BTreeMap<String, String>>,
|
||||
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub billing_active: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_serviceable: bool,
|
||||
#[serde(default)]
|
||||
pub catalog_mode: BillingCatalogMode,
|
||||
#[serde(default)]
|
||||
pub webhook_url: String,
|
||||
pub automatic_tax: Option<bool>,
|
||||
pub tax_id_collection: Option<bool>,
|
||||
pub terms_consent_required: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub effective_automatic_tax: bool,
|
||||
#[serde(default)]
|
||||
pub effective_tax_id_collection: bool,
|
||||
#[serde(default)]
|
||||
pub effective_terms_consent_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceBillingUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stripe_secret_key: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stripe_webhook_secret: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_currency: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub automatic_tax: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tax_id_collection: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub terms_consent_required: Option<Option<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscovery {
|
||||
#[serde(default)]
|
||||
pub app_public: InstancePremiumDiscoveryAppPublic,
|
||||
#[serde(default)]
|
||||
pub features: InstancePremiumDiscoveryFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryAppPublic {
|
||||
#[serde(default)]
|
||||
pub branding: InstancePremiumDiscoveryBranding,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryBranding {
|
||||
pub premium_product_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryFeatures {
|
||||
#[serde(default)]
|
||||
pub premium_enabled: bool,
|
||||
}
|
||||
|
||||
impl InstancePremiumDiscovery {
|
||||
pub fn premium_product_name(&self) -> Option<&str> {
|
||||
self.app_public
|
||||
.branding
|
||||
.premium_product_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PremiumBranding {
|
||||
pub name: Option<String>,
|
||||
pub premium_enabled: bool,
|
||||
}
|
||||
|
||||
impl PremiumBranding {
|
||||
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
|
||||
Self {
|
||||
name: discovery.premium_product_name().map(str::to_owned),
|
||||
premium_enabled: discovery.features.premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
|
||||
Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&config.app_public.branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
|
||||
let name = name.trim();
|
||||
Self {
|
||||
name: (!name.is_empty()).then(|| name.to_owned()),
|
||||
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn billing_response_round_trips_through_the_generated_contract() {
|
||||
let value = json!({
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
"stripe_secret_key_set": true,
|
||||
"stripe_webhook_secret_set": false,
|
||||
"stripe_secret_key_stored": true,
|
||||
"stripe_webhook_secret_stored": false,
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1Monthly",
|
||||
"yearly": "price_1Yearly",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP"},
|
||||
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
|
||||
"billing_active": false,
|
||||
"stripe_serviceable": false,
|
||||
"catalog_mode": "operator",
|
||||
"webhook_url": "https://api.example.com/stripe/webhook",
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": false,
|
||||
"terms_consent_required": true,
|
||||
"effective_automatic_tax": false,
|
||||
"effective_tax_id_collection": false,
|
||||
"effective_terms_consent_required": true
|
||||
});
|
||||
let generated: generated_types::InstanceBillingResponse =
|
||||
serde_json::from_value(value.clone()).expect("generated billing response");
|
||||
let ours: InstanceBillingResponse =
|
||||
serde_json::from_value(value.clone()).expect("hand-written billing response");
|
||||
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
|
||||
assert!(ours.stripe_secret_key_stored);
|
||||
assert_eq!(ours.automatic_tax, None);
|
||||
assert_eq!(ours.tax_id_collection, Some(false));
|
||||
assert!(ours.effective_terms_consent_required);
|
||||
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
|
||||
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated).expect("serializable generated"),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_billing_response_matches_the_generated_contract() {
|
||||
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
|
||||
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
|
||||
.expect("generated billing response");
|
||||
assert_eq!(value["catalog_mode"], json!("env"));
|
||||
assert_eq!(value["prices"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
|
||||
let mut prices = BTreeMap::new();
|
||||
prices.insert(
|
||||
"SEK".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1Monthly".to_owned()),
|
||||
yearly: Some("price_1Yearly".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let update = InstanceBillingUpdateRequest {
|
||||
enabled: Some(None),
|
||||
stripe_secret_key: Some(None),
|
||||
default_currency: Some(None),
|
||||
prices: Some(Some(prices)),
|
||||
country_currencies: Some(None),
|
||||
legacy_prices: Some(Some(BTreeMap::new())),
|
||||
automatic_tax: Some(None),
|
||||
tax_id_collection: Some(Some(true)),
|
||||
terms_consent_required: Some(Some(false)),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"enabled": null,
|
||||
"stripe_secret_key": null,
|
||||
"default_currency": null,
|
||||
"prices": {
|
||||
"SEK": {
|
||||
"monthly": "price_1Monthly",
|
||||
"yearly": "price_1Yearly",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": null,
|
||||
"legacy_prices": {},
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(InstanceBillingUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_discovery_reads_the_name_and_feature_flag() {
|
||||
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
|
||||
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
|
||||
"features": {"premium_enabled": true, "stripe_enabled": false}
|
||||
}))
|
||||
.expect("discovery");
|
||||
assert_eq!(discovery.premium_product_name(), Some("Gold"));
|
||||
assert!(discovery.features.premium_enabled);
|
||||
let empty: InstancePremiumDiscovery =
|
||||
serde_json::from_value(json!({})).expect("empty discovery");
|
||||
assert_eq!(empty.premium_product_name(), None);
|
||||
assert!(!empty.features.premium_enabled);
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&discovery),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_branding_from_instance_config_matches_discovery_rules() {
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
@@ -21,9 +21,17 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -326,6 +334,11 @@ pub struct AppBrandingConfigResponse {
|
||||
pub wordmark_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub theme_color: Option<String>,
|
||||
pub status_page_url: Option<String>,
|
||||
pub status_page_incident_history_url: Option<String>,
|
||||
#[serde(default = "default_premium_product_name")]
|
||||
pub premium_product_name: String,
|
||||
pub premium_info_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppBrandingConfigResponse {
|
||||
@@ -338,6 +351,10 @@ impl Default for AppBrandingConfigResponse {
|
||||
wordmark_url: None,
|
||||
favicon_url: None,
|
||||
theme_color: None,
|
||||
status_page_url: None,
|
||||
status_page_incident_history_url: None,
|
||||
premium_product_name: default_premium_product_name(),
|
||||
premium_info_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +363,10 @@ fn default_product_name() -> String {
|
||||
"Fluxer".to_owned()
|
||||
}
|
||||
|
||||
fn default_premium_product_name() -> String {
|
||||
"Premium".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct AppSetupConfigResponse {
|
||||
#[serde(default)]
|
||||
@@ -442,99 +463,183 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
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";
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushRelayConfigResponse {
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PushRelayConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
pub allow_user_override: bool,
|
||||
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 guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub stereo_enabled: bool,
|
||||
pub suppression_strength: u32,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
impl Default for DomainMigrationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "voice-ns-v1".to_owned(),
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
suppression_strength: 80,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: 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 guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stereo_enabled: Option<bool>,
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AltchaCaptchaConfigResponse {
|
||||
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 {
|
||||
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,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
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)]
|
||||
@@ -651,9 +756,17 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing: Option<InstanceBillingUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -858,6 +971,14 @@ pub struct AppBrandingConfigUpdateRequest {
|
||||
pub favicon_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme_color: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_page_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_page_incident_history_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub premium_product_name: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub premium_info_url: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -962,41 +1083,51 @@ mod tests {
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
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 delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise 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 delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
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_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
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
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
@@ -1004,7 +1135,9 @@ mod tests {
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1017,25 +1150,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = DomainMigrationConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
guild_overrides: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ mod codes;
|
||||
mod common;
|
||||
mod discovery;
|
||||
mod guild_assets;
|
||||
mod instance_billing;
|
||||
mod instance_config;
|
||||
mod jobs;
|
||||
mod limit_config;
|
||||
@@ -28,6 +29,7 @@ pub use codes::*;
|
||||
pub use common::*;
|
||||
pub use discovery::*;
|
||||
pub use guild_assets::*;
|
||||
pub use instance_billing::*;
|
||||
pub use instance_config::*;
|
||||
pub use jobs::*;
|
||||
pub use limit_config::*;
|
||||
|
||||
@@ -95,8 +95,8 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserFlagsUpdateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
add_flags: user_flags(add_flags)?,
|
||||
remove_flags: user_flags(remove_flags)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -567,11 +567,13 @@ fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::UserFlags::from)
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
client::ApiError,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
|
||||
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
|
||||
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
|
||||
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
|
||||
},
|
||||
},
|
||||
middleware::flash::FlashData,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const PRICE_ID_MAX_CHARS: usize = 255;
|
||||
const INFO_URL_MAX_CHARS: usize = 2048;
|
||||
|
||||
pub(super) fn build_billing_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let premium_product_name = if form.contains_key("billing_premium_product_name") {
|
||||
Some(parse_premium_product_name(
|
||||
form.clean("billing_premium_product_name"),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let premium_info_url = if form.contains_key("billing_premium_info_url") {
|
||||
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
|
||||
AppBrandingConfigUpdateRequest {
|
||||
premium_product_name,
|
||||
premium_info_url,
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let prices = if form.contains_key("billing_price_currency") {
|
||||
Some(parse_price_rows(form)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let default_currency = if form.contains_key("billing_default_currency") {
|
||||
Some(
|
||||
form.clean("billing_default_currency")
|
||||
.map(|value| parse_currency(&value))
|
||||
.transpose()?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let country_currencies = if form.contains_key("billing_country_currencies") {
|
||||
Some(parse_country_currencies(
|
||||
form.first("billing_country_currencies").unwrap_or(""),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let legacy_prices = if form.contains_key("billing_legacy_prices") {
|
||||
Some(parse_legacy_prices(
|
||||
form.first("billing_legacy_prices").unwrap_or(""),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Some(prices)) = &prices {
|
||||
if let Some(Some(currency)) = &default_currency
|
||||
&& !prices.contains_key(currency)
|
||||
{
|
||||
return Err(format!(
|
||||
"Default currency {currency} has no row in the price table"
|
||||
));
|
||||
}
|
||||
if let Some(Some(countries)) = &country_currencies
|
||||
&& let Some((country, currency)) = countries
|
||||
.iter()
|
||||
.find(|(_, currency)| !prices.contains_key(*currency))
|
||||
{
|
||||
return Err(format!(
|
||||
"{country} maps to {currency}, which has no row in the price table"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
|
||||
branding: Some(branding),
|
||||
..Default::default()
|
||||
}),
|
||||
billing: Some(InstanceBillingUpdateRequest {
|
||||
enabled: parse_tri_state(form, "billing_enabled")?,
|
||||
stripe_secret_key: secret_update(
|
||||
form,
|
||||
"billing_stripe_secret_key",
|
||||
"billing_clear_stripe_secret_key",
|
||||
),
|
||||
stripe_webhook_secret: secret_update(
|
||||
form,
|
||||
"billing_stripe_webhook_secret",
|
||||
"billing_clear_stripe_webhook_secret",
|
||||
),
|
||||
default_currency,
|
||||
prices,
|
||||
country_currencies,
|
||||
legacy_prices,
|
||||
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
|
||||
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
|
||||
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
|
||||
if !form.contains_key(key) {
|
||||
return Ok(None);
|
||||
}
|
||||
match form.first(key).map(str::trim).unwrap_or("") {
|
||||
TRI_STATE_DEFAULT => Ok(Some(None)),
|
||||
TRI_STATE_ON => Ok(Some(Some(true))),
|
||||
TRI_STATE_OFF => Ok(Some(Some(false))),
|
||||
other => Err(format!("Invalid choice \"{other}\" for {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
||||
match result {
|
||||
Ok(_) => FlashData::success("Premium and billing settings updated"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update billing config");
|
||||
match validation_message(&error) {
|
||||
Some(message) => FlashData::error(format!(
|
||||
"Failed to update premium and billing settings: {message}"
|
||||
)),
|
||||
None => FlashData::error("Failed to update premium and billing settings"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
} = error
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(message).ok()?;
|
||||
let first = body["errors"].as_array().and_then(|errors| errors.first());
|
||||
let detail = first.and_then(|error| {
|
||||
let message = error["message"].as_str()?;
|
||||
Some(
|
||||
match error["path"].as_str().filter(|path| !path.is_empty()) {
|
||||
Some(path) => format!("{path}: {message}"),
|
||||
None => message.to_owned(),
|
||||
},
|
||||
)
|
||||
});
|
||||
detail.or_else(|| body["message"].as_str().map(str::to_owned))
|
||||
}
|
||||
|
||||
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
|
||||
match form.clean(key) {
|
||||
Some(secret) => Some(Some(secret)),
|
||||
None if form.bool_value(clear_key) => Some(None),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
|
||||
match value {
|
||||
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
|
||||
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
|
||||
)),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
|
||||
&& url::Url::parse(&value).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& url.host_str().is_some_and(|h| !h.is_empty())
|
||||
});
|
||||
if valid {
|
||||
Ok(Some(value))
|
||||
} else {
|
||||
Err("Premium info URL must be an absolute http or https URL".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_currency(value: &str) -> Result<String, String> {
|
||||
let currency = value.trim().to_ascii_uppercase();
|
||||
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
|
||||
Ok(currency)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
|
||||
value.trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_country(value: &str) -> Result<String, String> {
|
||||
let country = value.trim().to_ascii_uppercase();
|
||||
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
|
||||
Ok(country)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
|
||||
value.trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_price_id(value: &str) -> Result<String, String> {
|
||||
let id = value.trim();
|
||||
let valid = id.len() <= PRICE_ID_MAX_CHARS
|
||||
&& id.strip_prefix("price_").is_some_and(|rest| {
|
||||
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
});
|
||||
if valid {
|
||||
Ok(id.to_owned())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
|
||||
match value
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(id) => parse_price_id(id).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_price_rows(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
|
||||
let currencies = form.values("billing_price_currency");
|
||||
let column = |key: &str, index: usize| form.values(key).get(index);
|
||||
let mut prices = BTreeMap::new();
|
||||
for (index, currency) in currencies.iter().enumerate() {
|
||||
if currency.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let currency = parse_currency(currency)?;
|
||||
let set = BillingPriceSet {
|
||||
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
|
||||
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
|
||||
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
|
||||
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
|
||||
};
|
||||
if set.is_empty() {
|
||||
return Err(format!("{currency} needs at least one price ID"));
|
||||
}
|
||||
if prices.insert(currency.clone(), set).is_some() {
|
||||
return Err(format!(
|
||||
"{currency} appears more than once in the price table"
|
||||
));
|
||||
}
|
||||
}
|
||||
if prices.len() > BILLING_MAX_CURRENCIES {
|
||||
return Err(format!(
|
||||
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
|
||||
));
|
||||
}
|
||||
Ok((!prices.is_empty()).then_some(prices))
|
||||
}
|
||||
|
||||
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
|
||||
value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
line.split_once('=')
|
||||
.map(|(key, value)| (key.trim(), value.trim()))
|
||||
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
|
||||
let mut countries = BTreeMap::new();
|
||||
for line in key_value_lines(value) {
|
||||
let (country, currency) = line?;
|
||||
let country = parse_country(country)?;
|
||||
let currency = parse_currency(currency)?;
|
||||
if countries.insert(country.clone(), currency).is_some() {
|
||||
return Err(format!("{country} is mapped more than once"));
|
||||
}
|
||||
}
|
||||
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
|
||||
return Err(format!(
|
||||
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
|
||||
));
|
||||
}
|
||||
Ok((!countries.is_empty()).then_some(countries))
|
||||
}
|
||||
|
||||
fn parse_legacy_slot(value: &str) -> Result<String, String> {
|
||||
let invalid = || {
|
||||
format!(
|
||||
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
|
||||
)
|
||||
};
|
||||
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
|
||||
let slot = slot.to_ascii_lowercase();
|
||||
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let currency = parse_currency(currency).map_err(|_| invalid())?;
|
||||
Ok(format!("{slot}_{currency}"))
|
||||
}
|
||||
|
||||
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
|
||||
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for line in key_value_lines(value) {
|
||||
let (slot, ids) = line?;
|
||||
let slot = parse_legacy_slot(slot)?;
|
||||
let entry = legacy.entry(slot.clone()).or_default();
|
||||
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
|
||||
let id = parse_price_id(id)?;
|
||||
if !entry.contains(&id) {
|
||||
entry.push(id);
|
||||
}
|
||||
}
|
||||
if entry.is_empty() {
|
||||
return Err(format!("{slot} needs at least one price ID"));
|
||||
}
|
||||
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
|
||||
return Err(format!(
|
||||
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
|
||||
));
|
||||
}
|
||||
}
|
||||
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
|
||||
return Err(format!(
|
||||
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
|
||||
));
|
||||
}
|
||||
Ok((!legacy.is_empty()).then_some(legacy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
fn full_form(extra: &str) -> MultiValueForm {
|
||||
let base = "billing_premium_product_name=%20Gold%20\
|
||||
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
|
||||
&billing_enabled=on\
|
||||
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
|
||||
&billing_stripe_secret_key=\
|
||||
&billing_stripe_webhook_secret=whsec_new\
|
||||
&billing_default_currency=gbp\
|
||||
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
|
||||
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=\
|
||||
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=\
|
||||
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
|
||||
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
|
||||
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_billing_form_builds_the_expected_patch() {
|
||||
let update = build_billing_update(&full_form("")).expect("valid form");
|
||||
let value = serde_json::to_value(&update).expect("serializable");
|
||||
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"premium_product_name": "Gold",
|
||||
"premium_info_url": "https://example.com/gold"
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": true,
|
||||
"stripe_webhook_secret": "whsec_new",
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1GbpM",
|
||||
"yearly": "price_1GbpY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": "price_1GbpG"
|
||||
},
|
||||
"SEK": {
|
||||
"monthly": "price_1SekM",
|
||||
"yearly": "price_1SekY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP", "SE": "SEK"},
|
||||
"legacy_prices": {
|
||||
"monthly_GBP": ["price_1OldA", "price_1OldB"],
|
||||
"yearly_SEK": ["price_1OldC"]
|
||||
},
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_fields_clear_and_the_default_choice_sends_null() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
|
||||
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
|
||||
&billing_stripe_webhook_secret=\
|
||||
&billing_default_currency=\
|
||||
&billing_price_currency=&billing_price_monthly=price_1Ignored\
|
||||
&billing_country_currencies=&billing_legacy_prices=",
|
||||
);
|
||||
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"app_public": {
|
||||
"branding": {"premium_product_name": null, "premium_info_url": null}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": null,
|
||||
"stripe_secret_key": null,
|
||||
"default_currency": null,
|
||||
"prices": null,
|
||||
"country_currencies": null,
|
||||
"legacy_prices": null
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_secret_wins_over_the_clear_checkbox() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
|
||||
);
|
||||
let billing = build_billing_update(&form)
|
||||
.expect("valid form")
|
||||
.billing
|
||||
.expect("billing");
|
||||
assert_eq!(
|
||||
billing.stripe_secret_key,
|
||||
Some(Some("sk_live_x".to_owned()))
|
||||
);
|
||||
assert_eq!(billing.stripe_webhook_secret, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_form_keys_leave_their_fields_untouched() {
|
||||
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
|
||||
.expect("valid form");
|
||||
assert!(update.app_public.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(update.billing).unwrap(),
|
||||
json!({"enabled": false})
|
||||
);
|
||||
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
|
||||
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_rejected_with_a_message() {
|
||||
let cases: &[(&str, &str)] = &[
|
||||
(
|
||||
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
|
||||
"http or https",
|
||||
),
|
||||
("billing_premium_info_url=example.com", "http or https"),
|
||||
("billing_default_currency=GB", "Invalid currency"),
|
||||
("billing_enabled=true", "Invalid choice"),
|
||||
("billing_automatic_tax=maybe", "Invalid choice"),
|
||||
(
|
||||
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
|
||||
"Invalid currency",
|
||||
),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
|
||||
"Invalid Stripe price ID",
|
||||
),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
|
||||
"Invalid Stripe price ID",
|
||||
),
|
||||
("billing_price_currency=GBP", "needs at least one price ID"),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
|
||||
"more than once",
|
||||
),
|
||||
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
|
||||
("billing_country_currencies=SE", "KEY=VALUE"),
|
||||
(
|
||||
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
|
||||
"mapped more than once",
|
||||
),
|
||||
(
|
||||
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
|
||||
"Invalid legacy price slot",
|
||||
),
|
||||
(
|
||||
"billing_legacy_prices=monthly_GBP%3D",
|
||||
"needs at least one price ID",
|
||||
),
|
||||
(
|
||||
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
|
||||
"Default currency EUR has no row",
|
||||
),
|
||||
(
|
||||
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
|
||||
"SE maps to SEK",
|
||||
),
|
||||
];
|
||||
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
|
||||
let emoji_name = format!(
|
||||
"billing_premium_product_name=Gold{}",
|
||||
"%F0%9F%92%8E".repeat(20)
|
||||
);
|
||||
let long_case = [
|
||||
(long_name.as_str(), "at most 40"),
|
||||
(emoji_name.as_str(), "at most 40"),
|
||||
];
|
||||
for (body, expected) in long_case.iter().chain(cases.iter()) {
|
||||
let error =
|
||||
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
|
||||
assert!(error.contains(expected), "{body}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_limit_counts_utf16_units() {
|
||||
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
|
||||
assert_eq!(name.chars().count(), 40);
|
||||
assert!(parse_premium_product_name(Some(name)).is_err());
|
||||
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
|
||||
assert_eq!(
|
||||
parse_premium_product_name(Some(fits.clone())),
|
||||
Ok(Some(fits))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn country_currencies_are_not_cross_checked_without_a_price_table() {
|
||||
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
|
||||
let billing = build_billing_update(&form).unwrap().billing.unwrap();
|
||||
assert_eq!(
|
||||
billing.country_currencies,
|
||||
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_errors_surface_the_first_api_message() {
|
||||
let error = ApiError::Http {
|
||||
status: 400,
|
||||
message: json!({
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
validation_message(&error).as_deref(),
|
||||
Some("billing.enabled: Switch the premium model to mirror first")
|
||||
);
|
||||
let server_error = ApiError::Http {
|
||||
status: 500,
|
||||
message: "{}".to_owned(),
|
||||
};
|
||||
assert_eq!(validation_message(&server_error), None);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ use crate::{
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
|
||||
templates::{
|
||||
self,
|
||||
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
|
||||
},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::{FromRequest, Query, Request, State},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -46,10 +49,11 @@ async fn gift_codes_page(
|
||||
Query(query): Query<GiftCodesQuery>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
|
||||
if config.self_hosted {
|
||||
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let premium = GiftCodesPremium::from_branding(
|
||||
config.self_hosted,
|
||||
state.premium_branding(&client).await.as_ref(),
|
||||
);
|
||||
|
||||
let generated_codes: Option<Vec<String>> = query
|
||||
.codes
|
||||
@@ -60,6 +64,7 @@ async fn gift_codes_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
&premium,
|
||||
generated_codes.as_deref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
@@ -72,9 +77,6 @@ async fn gift_codes_post(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
if config.self_hosted {
|
||||
return Redirect::to(&format!("{base}/dashboard")).into_response();
|
||||
}
|
||||
let form: GiftCodesForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn render(
|
||||
admin_user_id: None,
|
||||
target_id: Some(guild_id.to_owned()),
|
||||
target_type: Some("guild".to_owned()),
|
||||
access: Some("write".to_owned()),
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit: 50,
|
||||
@@ -134,7 +135,7 @@ pub async fn render(
|
||||
@if let Some(resp) = resp {
|
||||
@if resp.logs.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No admin audit log entries for this guild."
|
||||
"No admin write actions have been recorded for this guild."
|
||||
}
|
||||
} @else {
|
||||
(table_container(table(maud::html! {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod applications;
|
||||
pub mod auth;
|
||||
pub mod bans;
|
||||
mod bans_actions;
|
||||
mod billing_actions;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
mod guild_tabs;
|
||||
|
||||
@@ -80,7 +80,7 @@ async fn reports_list(
|
||||
return reports_error_page(
|
||||
config,
|
||||
&auth.0,
|
||||
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
|
||||
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
|
||||
);
|
||||
}
|
||||
let search_query = query.q.as_deref().and_then(clean_string);
|
||||
|
||||
@@ -28,6 +28,7 @@ struct AuditLogsQuery {
|
||||
admin_user_id: Option<String>,
|
||||
target_id: Option<String>,
|
||||
target_type: Option<String>,
|
||||
access: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
limit: Option<u32>,
|
||||
@@ -119,6 +120,7 @@ async fn audit_logs_page(
|
||||
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
|
||||
target_id: query.target_id.as_deref().unwrap_or(""),
|
||||
target_type: query.target_type.as_deref().unwrap_or(""),
|
||||
access: query.access.as_deref().unwrap_or(""),
|
||||
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
|
||||
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
|
||||
limit,
|
||||
@@ -131,6 +133,7 @@ async fn audit_logs_page(
|
||||
admin_user_id: nonempty(params.admin_user_id),
|
||||
target_id: nonempty(params.target_id),
|
||||
target_type: nonempty(params.target_type),
|
||||
access: nonempty(params.access),
|
||||
sort_by: Some(params.sort_by.to_owned()),
|
||||
sort_order: Some(params.sort_order.to_owned()),
|
||||
limit,
|
||||
@@ -218,6 +221,11 @@ async fn instance_config_page(
|
||||
.get_instance_config()
|
||||
.await
|
||||
.log_error("load instance config");
|
||||
if let Some(instance_config) = &instance_config {
|
||||
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
|
||||
instance_config,
|
||||
));
|
||||
}
|
||||
let limit_config = client
|
||||
.get_limit_config()
|
||||
.await
|
||||
|
||||
@@ -4,23 +4,24 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
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,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -193,7 +194,9 @@ pub async fn instance_config_post(
|
||||
}
|
||||
"update_policy" => {
|
||||
let update = build_policy_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
let result = client.update_instance_config(&update).await;
|
||||
remember_premium_branding(&state, &result);
|
||||
instance_config_result(result)
|
||||
}
|
||||
"update_integrations" => {
|
||||
let update = build_integrations_update(&form);
|
||||
@@ -203,7 +206,27 @@ pub async fn instance_config_post(
|
||||
let update = build_media_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
|
||||
"update_billing" => match super::billing_actions::build_billing_update(&form) {
|
||||
Ok(update) => {
|
||||
let result = client.update_instance_config(&update).await;
|
||||
remember_premium_branding(&state, &result);
|
||||
super::billing_actions::billing_result(result)
|
||||
}
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_push_relay" => {
|
||||
let update = build_push_relay_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
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) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -334,6 +357,17 @@ pub async fn instance_config_post(
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn remember_premium_branding(
|
||||
state: &AppState,
|
||||
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
||||
) {
|
||||
if let Ok(instance_config) = result {
|
||||
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
|
||||
instance_config,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_registration_url_list_response(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
@@ -447,10 +481,9 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -476,35 +509,42 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
fn parse_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
if !salt
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
|
||||
{
|
||||
return Err("Rollout salt must use printable ASCII".to_owned());
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -513,9 +553,9 @@ fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Ve
|
||||
if ids.iter().any(|existing| existing == candidate) {
|
||||
continue;
|
||||
}
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
|
||||
return Err(format!(
|
||||
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
|
||||
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
|
||||
));
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
@@ -523,107 +563,138 @@ fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Ve
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
for (index, line) in value.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line_number = index + 1;
|
||||
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
}
|
||||
let backend = backend.trim().parse().map_err(|_| {
|
||||
format!("Guild overrides line {line_number} must name a supported backend")
|
||||
})?;
|
||||
if let Some(existing) = overrides
|
||||
.iter()
|
||||
.find(|existing| existing.guild_id == guild_id)
|
||||
{
|
||||
if existing.backend != backend {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
return Err(format!(
|
||||
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
|
||||
));
|
||||
}
|
||||
overrides.push(VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: guild_id.to_owned(),
|
||||
backend,
|
||||
});
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(
|
||||
fn build_domain_migration_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let selected: Vec<NoiseSuppressionBackend> = form
|
||||
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Enabled backends must name supported noise suppression backends".to_owned()
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let enabled_backends = NoiseSuppressionBackend::ALL
|
||||
.into_iter()
|
||||
.filter(|backend| selected.contains(backend))
|
||||
.collect();
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Default backend must name a supported noise suppression backend".to_owned()
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
enabled_backends: Some(enabled_backends),
|
||||
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
|
||||
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("domain_migration_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)?),
|
||||
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
|
||||
suppression_strength: parse_form_number(
|
||||
anonymous_rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength",
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout basis points",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_altcha_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")),
|
||||
cost: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_cost",
|
||||
"Cost",
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.end(),
|
||||
)?,
|
||||
max_counter: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*ALTCHA_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()
|
||||
})
|
||||
@@ -683,6 +754,9 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
wordmark_url: optional("app_wordmark_url"),
|
||||
favicon_url: optional("app_favicon_url"),
|
||||
theme_color: optional("app_theme_color"),
|
||||
status_page_url: optional("app_status_page_url"),
|
||||
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
|
||||
..Default::default()
|
||||
}),
|
||||
setup: Some(AppSetupConfigUpdateRequest {
|
||||
configured: Some(form.bool_value("app_setup_configured")),
|
||||
@@ -1234,60 +1308,76 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
);
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_backends,
|
||||
Some(vec![
|
||||
NoiseSuppressionBackend::None,
|
||||
NoiseSuppressionBackend::DeepFilter
|
||||
])
|
||||
);
|
||||
assert_eq!(update.rollout_basis_points, Some(10_000));
|
||||
assert_eq!(update.suppression_strength, Some(100));
|
||||
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"allow_user_override": false,
|
||||
"stereo_enabled": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
}})
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[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",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
.domain_migration
|
||||
.expect("domain migration update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
@@ -1302,259 +1392,231 @@ mod tests {
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_domain_migration_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
|
||||
for (line, message) in [
|
||||
("456", "Guild overrides line 3 must use guild_id=backend"),
|
||||
(
|
||||
"=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"not-a-guild=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"999999999999999999999=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"456=unknown_backend",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"456=",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"123=gate",
|
||||
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
|
||||
.expect_err("invalid guild rule"),
|
||||
message,
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
|
||||
for (key, message, above_max) in [
|
||||
(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"10001",
|
||||
),
|
||||
(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength must be a whole number between 0 and 100",
|
||||
"101",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
|
||||
for salt in [
|
||||
String::new(),
|
||||
" ".to_owned(),
|
||||
"é".repeat(65),
|
||||
"🎲".repeat(33),
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
|
||||
"Rollout salt must be between 1 and 64 characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
|
||||
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
|
||||
let form =
|
||||
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, Some(salt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
|
||||
).expect("valid guild rules");
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
|
||||
.map(|index| format!("{index}=gate"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let overrides =
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
|
||||
.expect("valid guild rules at cap");
|
||||
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
|
||||
assert_eq!(
|
||||
overrides.last().map(|entry| entry.guild_id.as_str()),
|
||||
Some("199")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
|
||||
.expect_err("too many guild rules"),
|
||||
"Guild overrides must contain at most 200 unique guilds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_ns_default_backend=unknown",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_default_backend=",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
|
||||
"Enabled backends must name supported noise suppression backends",
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_included_user_ids=123%2Cinvalid",
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_excluded_user_ids=123%2Cinvalid",
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
|
||||
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&unchecked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
|
||||
);
|
||||
|
||||
let checked = build_push_relay_update(&MultiValueForm::parse(
|
||||
b"_csrf=token&push_relay_consent_accepted=true",
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&checked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_reads_the_rollout_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",
|
||||
);
|
||||
let update = build_altcha_captcha_update(&form)
|
||||
.expect("valid form")
|
||||
.altcha_captcha
|
||||
.expect("altcha 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() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_altcha_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,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_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",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_max_counter=1000001",
|
||||
"Maximum counter must be a whole number between 100 and 1000000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_altcha_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_reads_the_rollout_fields() {
|
||||
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()
|
||||
])
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -73,15 +73,11 @@ pub async fn render(
|
||||
.map(|r| r.sessions)
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
|
||||
.unwrap_or_default();
|
||||
let webauthn_credentials = if u.authenticator_types.contains(&2) {
|
||||
client
|
||||
.list_webauthn_credentials(user_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let webauthn_credentials = client
|
||||
.list_webauthn_credentials(user_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::account::account_tab(
|
||||
config,
|
||||
&u,
|
||||
@@ -244,7 +240,8 @@ pub async fn render(
|
||||
query: None,
|
||||
admin_user_id: None,
|
||||
target_id: Some(user_id.to_owned()),
|
||||
target_type: Some("user".to_owned()),
|
||||
target_type: None,
|
||||
access: Some("write".to_owned()),
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit,
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResult, ApiResultExt},
|
||||
types::AdminUser,
|
||||
types::{AdminUser, PremiumBranding},
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
|
||||
routes::user_tabs,
|
||||
@@ -22,6 +22,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
const USER_ID_LOOKUP_BATCH: usize = 100;
|
||||
const DEFAULT_PREMIUM_NAME: &str = "Premium";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserListQuery {
|
||||
@@ -87,32 +88,47 @@ async fn users_list(
|
||||
.unwrap_or(&[]);
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let results = if params.has_id_lookup() {
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_users(
|
||||
params.search_query(),
|
||||
params.email_query(),
|
||||
params.ip_query(),
|
||||
params.limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.log_error("search users")
|
||||
.map(|r| {
|
||||
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||
(r.users, has_more)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let searching = params.has_id_lookup() || params.has_search();
|
||||
let results = async {
|
||||
if params.has_id_lookup() {
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_users(
|
||||
params.search_query(),
|
||||
params.email_query(),
|
||||
params.ip_query(),
|
||||
params.limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.log_error("search users")
|
||||
.map(|r| {
|
||||
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||
(r.users, has_more)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let badge = async {
|
||||
if searching {
|
||||
self_hosted_premium_badge_name(&state, &client).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let (results, badge_name) = tokio::join!(results, badge);
|
||||
let result_users = results.as_ref().map(|r| r.0.as_slice());
|
||||
let has_more = results.as_ref().is_some_and(|r| r.1);
|
||||
let premium_badge_name = match result_users {
|
||||
Some(users) if !users.is_empty() => badge_name,
|
||||
_ => None,
|
||||
};
|
||||
let markup = templates::pages::users_list::users_list_page(
|
||||
config,
|
||||
&auth.0,
|
||||
@@ -120,11 +136,34 @@ async fn users_list(
|
||||
result_users,
|
||||
has_more,
|
||||
can_view_email,
|
||||
premium_badge_name.as_deref(),
|
||||
is_results_fragment,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn self_hosted_premium_badge_name(
|
||||
state: &AppState,
|
||||
client: &AdminApiClient,
|
||||
) -> Option<String> {
|
||||
if !state.config().self_hosted {
|
||||
return None;
|
||||
}
|
||||
premium_badge_name(state.premium_branding(client).await.as_ref())
|
||||
}
|
||||
|
||||
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
|
||||
match branding {
|
||||
Some(branding) => branding.premium_enabled.then(|| {
|
||||
branding
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
|
||||
}),
|
||||
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_users_in_batches(
|
||||
client: &AdminApiClient,
|
||||
user_ids: &[String],
|
||||
@@ -148,10 +187,15 @@ async fn user_detail(
|
||||
let is_detail_fragment = htmx::targets(&headers, "main-content");
|
||||
let active_tab = query.tab.as_deref().unwrap_or("overview");
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let user = client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user detail");
|
||||
let (user, badge_name) = tokio::join!(
|
||||
async {
|
||||
client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user detail")
|
||||
},
|
||||
self_hosted_premium_badge_name(&state, &client)
|
||||
);
|
||||
let tq = to_tab_query(&query);
|
||||
let admin_acls = auth
|
||||
.0
|
||||
@@ -167,6 +211,7 @@ async fn user_detail(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let premium_badge_name = user.as_ref().and(badge_name);
|
||||
let markup = templates::pages::user_detail::user_detail_with_tab(
|
||||
config,
|
||||
&auth.0,
|
||||
@@ -174,6 +219,7 @@ async fn user_detail(
|
||||
&user_id,
|
||||
active_tab,
|
||||
tab_body,
|
||||
premium_badge_name.as_deref(),
|
||||
is_detail_fragment,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
@@ -275,18 +321,29 @@ async fn user_peek(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let user = client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user peek");
|
||||
let (user, badge_name) = tokio::join!(
|
||||
async {
|
||||
client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user peek")
|
||||
},
|
||||
self_hosted_premium_badge_name(&state, &client)
|
||||
);
|
||||
let admin_acls = auth
|
||||
.0
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let premium_badge_name = user.as_ref().and(badge_name);
|
||||
let markup = match user {
|
||||
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
|
||||
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
|
||||
config,
|
||||
u,
|
||||
admin_acls,
|
||||
premium_badge_name.as_deref(),
|
||||
),
|
||||
None => maud::html! {
|
||||
div class="p-4 text-red-600 text-sm" { "User not found." }
|
||||
},
|
||||
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
|
||||
url.push_str(&urlencoding::encode(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
|
||||
let gold = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
|
||||
let unnamed = PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(
|
||||
premium_badge_name(Some(&unnamed)).as_deref(),
|
||||
Some("Premium")
|
||||
);
|
||||
let everyone = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&everyone)), None);
|
||||
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::AdminConfig;
|
||||
use std::sync::Arc;
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::PremiumBranding,
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -11,6 +22,7 @@ pub struct AppState {
|
||||
struct AppStateInner {
|
||||
pub config: AdminConfig,
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -23,6 +35,7 @@ impl AppState {
|
||||
inner: Arc::new(AppStateInner {
|
||||
config,
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +47,40 @@ impl AppState {
|
||||
pub fn http_client(&self) -> &reqwest::Client {
|
||||
&self.inner.http_client
|
||||
}
|
||||
|
||||
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
|
||||
let cache = self
|
||||
.inner
|
||||
.premium_branding
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
cache
|
||||
.as_ref()
|
||||
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
|
||||
.map(|(_, branding)| branding.clone())
|
||||
}
|
||||
|
||||
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
|
||||
*self
|
||||
.inner
|
||||
.premium_branding
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
|
||||
}
|
||||
|
||||
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
|
||||
if let Some(branding) = self.cached_premium_branding() {
|
||||
return Some(branding);
|
||||
}
|
||||
let branding = PremiumBranding::from_discovery(
|
||||
&client
|
||||
.get_instance_premium_discovery()
|
||||
.await
|
||||
.log_error("load premium branding")?,
|
||||
);
|
||||
self.remember_premium_branding(branding.clone());
|
||||
Some(branding)
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn audit_logs_for_target(
|
||||
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
|
||||
let page_number = u64::from(current_page) + 1;
|
||||
let all_logs_href = format!(
|
||||
"{base_path}/audit-logs?target_id={}",
|
||||
"{base_path}/audit-logs?target_id={}&access=write",
|
||||
urlencoding::encode(target_id)
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn audit_logs_for_target(
|
||||
}
|
||||
}
|
||||
@if entries.is_empty() {
|
||||
(empty_state("No admin actions have been recorded against this entity."))
|
||||
(empty_state("No admin write actions have been recorded against this entity."))
|
||||
} @else {
|
||||
(table_container(html! {
|
||||
(table(html! {
|
||||
|
||||
@@ -13,12 +13,39 @@ struct BadgeDef {
|
||||
tooltip: String,
|
||||
}
|
||||
|
||||
fn premium_tooltip(
|
||||
premium_type: i32,
|
||||
premium_since: Option<&str>,
|
||||
is_self_hosted: bool,
|
||||
self_hosted_premium_name: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if is_self_hosted {
|
||||
let name = self_hosted_premium_name?;
|
||||
return Some(match premium_since {
|
||||
Some(since) => format!("{name} subscriber since {since}"),
|
||||
None => name.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(if premium_type == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user_profile_badges(
|
||||
static_cdn_endpoint: &str,
|
||||
flags: u64,
|
||||
premium_type: Option<i32>,
|
||||
premium_since: Option<&str>,
|
||||
is_self_hosted: bool,
|
||||
self_hosted_premium_name: Option<&str>,
|
||||
size_sm: bool,
|
||||
) -> Markup {
|
||||
let cdn = static_cdn_endpoint.trim_end_matches('/');
|
||||
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
|
||||
tooltip: "Fluxer Bug Hunter".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted
|
||||
&& let Some(pt) = premium_type
|
||||
if let Some(pt) = premium_type
|
||||
&& pt != premium_types::NONE
|
||||
&& let Some(tooltip) =
|
||||
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
|
||||
{
|
||||
let tooltip = if pt == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => {
|
||||
format!("Fluxer Plutonium subscriber since {since}")
|
||||
}
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
};
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/plutonium.svg"),
|
||||
tooltip,
|
||||
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
|
||||
user_profile_badges(
|
||||
"https://static.example.com",
|
||||
0,
|
||||
Some(premium_type),
|
||||
Some("2026-01-01"),
|
||||
self_hosted,
|
||||
name,
|
||||
false,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_premium_badges_keep_their_fluxer_labels() {
|
||||
assert!(
|
||||
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
|
||||
);
|
||||
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_premium_badges_use_the_configured_name() {
|
||||
let markup = render(true, Some("Gold"), 1);
|
||||
assert!(markup.contains("Gold subscriber since 2026-01-01"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
|
||||
assert!(!render(true, None, 1).contains("img"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
"bulk-actions",
|
||||
[
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
@@ -254,13 +255,36 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
],
|
||||
},
|
||||
NavSection {
|
||||
title: "Hosted Features",
|
||||
title: "Premium",
|
||||
items: &[item!(
|
||||
"Gift Codes",
|
||||
"/gift-codes",
|
||||
"gift-codes",
|
||||
[acl::GIFT_CODES_GENERATE],
|
||||
hosted
|
||||
[acl::GIFT_CODES_GENERATE]
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
|
||||
let item = NAV_SECTIONS
|
||||
.iter()
|
||||
.flat_map(|section| section.items)
|
||||
.find(|item| item.active_key == "bulk-actions")
|
||||
.expect("bulk actions nav item");
|
||||
for required in [
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
] {
|
||||
assert!(item.required_acls.contains(&required), "{required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
|
||||
pub admin_user_id: &'a str,
|
||||
pub target_id: &'a str,
|
||||
pub target_type: &'a str,
|
||||
pub access: &'a str,
|
||||
pub sort_by: &'a str,
|
||||
pub sort_order: &'a str,
|
||||
pub limit: u32,
|
||||
@@ -33,6 +34,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
("", "Any"),
|
||||
("user", "User"),
|
||||
("guild", "Guild"),
|
||||
("bulk_job", "Bulk job"),
|
||||
("email_domain", "Email domain"),
|
||||
("ip", "IP"),
|
||||
("phrase", "Phrase"),
|
||||
@@ -41,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
("file_sha", "File SHA"),
|
||||
("email", "Email"),
|
||||
];
|
||||
let access_options: &[(&str, &str)] = &[
|
||||
("", "All entries"),
|
||||
("write", "Writes only"),
|
||||
("read", "Reads only"),
|
||||
];
|
||||
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
|
||||
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
|
||||
let limit_options: &[(&str, &str)] =
|
||||
@@ -59,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
"Filter by admin user ID..."))
|
||||
(select_input("target_type", "Target type",
|
||||
target_type_options, params.target_type))
|
||||
(select_input("access", "Access", access_options, params.access))
|
||||
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
|
||||
(select_input("sort_order", "Order", order_options, params.sort_order))
|
||||
(select_input("limit", "Page size", limit_options, &limit_str))
|
||||
@@ -80,6 +88,7 @@ fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) ->
|
||||
("admin_user_id", params.admin_user_id),
|
||||
("target_id", params.target_id),
|
||||
("target_type", params.target_type),
|
||||
("access", params.access),
|
||||
("sort_by", params.sort_by),
|
||||
("sort_order", params.sort_order),
|
||||
]
|
||||
@@ -156,3 +165,48 @@ pub fn audit_logs_page(
|
||||
};
|
||||
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_type_filter_offers_bulk_jobs() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "",
|
||||
target_type: "bulk_job",
|
||||
access: "",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_filter_survives_form_and_pagination() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "1500000000000000001",
|
||||
target_type: "",
|
||||
access: "read",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<select id="access" name="access""#));
|
||||
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
|
||||
assert_eq!(
|
||||
build_pagination_url("/admin", 1, ¶ms),
|
||||
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}))
|
||||
},
|
||||
"bulk_job" => html! {
|
||||
(job_link(base, &entry.target_id, html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Bulk job" }
|
||||
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||
}
|
||||
}))
|
||||
},
|
||||
"message" => html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Message" }
|
||||
@@ -147,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
|
||||
html! {
|
||||
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_href(base: &str, channel_id: &str) -> String {
|
||||
format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
@@ -310,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"log_id": "1900000000000000001",
|
||||
"admin_user_id": "1500000000000000001",
|
||||
"action": "bulk_schedule_deletion",
|
||||
"target_id": target_id,
|
||||
"target_type": target_type,
|
||||
"audit_log_reason": "Raid cleanup",
|
||||
"created_at": "2026-09-14T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_job_targets_link_to_the_job_detail_page() {
|
||||
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
|
||||
assert!(markup.contains("Bulk job"));
|
||||
assert!(!markup.contains("/admin/users/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_target_types_stay_unlinked() {
|
||||
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||
assert!(!markup.contains("<a "));
|
||||
assert!(markup.contains("Email domain"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::{
|
||||
templates::{
|
||||
components::{
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
|
||||
submit_button, text_input, textarea_input,
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -395,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
|
||||
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id="reason_code" name="reason_code" required
|
||||
class=(FORM_SELECT_CLASS) {
|
||||
option value="" selected { "Select a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
))
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="14" min="14" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
}))
|
||||
(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."),
|
||||
html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="60" min="14" max="365" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
@@ -449,6 +466,21 @@ mod tests {
|
||||
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_has_no_preselected_reason() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||
for (value, _) in DELETION_REASONS {
|
||||
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::PremiumBranding,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
@@ -19,21 +20,52 @@ use maud::{Markup, html};
|
||||
pub const MAX_GIFT_CODES: u32 = 100;
|
||||
const DEFAULT_GIFT_COUNT: u32 = 10;
|
||||
|
||||
pub struct GiftCodesPremium {
|
||||
pub name: String,
|
||||
pub needs_mirror_mode: bool,
|
||||
}
|
||||
|
||||
impl GiftCodesPremium {
|
||||
pub fn from_branding(self_hosted: bool, branding: Option<&PremiumBranding>) -> Self {
|
||||
let default_name = if self_hosted { "Premium" } else { "Plutonium" };
|
||||
Self {
|
||||
name: branding
|
||||
.and_then(|branding| branding.name.as_deref())
|
||||
.unwrap_or(default_name)
|
||||
.to_owned(),
|
||||
needs_mirror_mode: self_hosted
|
||||
&& branding.is_some_and(|branding| !branding.premium_enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gift_codes_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
premium: &GiftCodesPremium,
|
||||
generated_codes: Option<&[String]>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let codes_value = generated_codes.map(|c| c.join("\n")).unwrap_or_default();
|
||||
let description = format!(
|
||||
"Create one-use {} gift URLs with a fixed positive duration. \
|
||||
Lifetime gifts cannot be generated here.",
|
||||
premium.name
|
||||
);
|
||||
|
||||
let content = html! {
|
||||
(page_header(
|
||||
"Gift Codes",
|
||||
Some("Create one-use Plutonium gift URLs with a fixed positive \
|
||||
duration. Lifetime gifts cannot be generated here."),
|
||||
))
|
||||
(page_header("Gift Codes", Some(&description)))
|
||||
|
||||
@if premium.needs_mirror_mode {
|
||||
(card(html! {
|
||||
p class="text-sm text-amber-700" {
|
||||
"The premium model is Everyone, so every member already has " (premium.name)
|
||||
" and gift codes cannot be generated or redeemed. Switch the premium model to \
|
||||
Mirror in Instance Config to use gift codes."
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
(card(html! {
|
||||
div class="flex flex-col gap-4" {
|
||||
@@ -107,3 +139,39 @@ pub fn gift_codes_page(
|
||||
};
|
||||
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
|
||||
PremiumBranding {
|
||||
name: Some(name.to_owned()),
|
||||
premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
|
||||
let hosted = GiftCodesPremium::from_branding(false, None);
|
||||
assert_eq!(hosted.name, "Plutonium");
|
||||
assert!(!hosted.needs_mirror_mode);
|
||||
let self_hosted = GiftCodesPremium::from_branding(true, None);
|
||||
assert_eq!(self_hosted.name, "Premium");
|
||||
assert!(!self_hosted.needs_mirror_mode);
|
||||
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
|
||||
assert_eq!(gold.name, "Gold");
|
||||
assert!(!gold.needs_mirror_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
|
||||
assert!(
|
||||
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
|
||||
);
|
||||
assert!(
|
||||
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
|
||||
.needs_mirror_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppBrandingConfigResponse, BillingCatalogMode, BillingPriceSet, InstanceBillingResponse,
|
||||
PremiumMode, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
|
||||
},
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, FORM_SELECT_CLASS, checkbox, csrf_input, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
},
|
||||
section_card::section_card_with_description,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PRICE_COLUMNS: [(&str, &str); 4] = [
|
||||
("billing_price_monthly", "Monthly"),
|
||||
("billing_price_yearly", "Yearly"),
|
||||
("billing_price_gift_1_month", "Gift 1 month"),
|
||||
("billing_price_gift_1_year", "Gift 1 year"),
|
||||
];
|
||||
|
||||
pub fn billing_blockers(
|
||||
billing: &InstanceBillingResponse,
|
||||
premium_mode: PremiumMode,
|
||||
) -> Vec<&'static str> {
|
||||
if billing.billing_active {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut blockers = Vec::new();
|
||||
if matches!(premium_mode, PremiumMode::Everyone) {
|
||||
blockers.push("the premium model is Everyone, so there is no paid tier to sell");
|
||||
}
|
||||
if !billing.effective_enabled {
|
||||
blockers.push("billing is not enabled");
|
||||
}
|
||||
if !billing.stripe_secret_key_set {
|
||||
blockers.push("no Stripe secret key is set");
|
||||
}
|
||||
let has_pair = billing
|
||||
.prices
|
||||
.as_ref()
|
||||
.is_some_and(|prices| prices.values().any(BillingPriceSet::has_recurring_pair));
|
||||
if billing.catalog_mode == BillingCatalogMode::Operator && !has_pair {
|
||||
blockers.push("no currency has both a monthly and a yearly price ID");
|
||||
}
|
||||
if blockers.is_empty() {
|
||||
blockers.push(match billing.catalog_mode {
|
||||
BillingCatalogMode::Env => {
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
}
|
||||
BillingCatalogMode::Operator => "the API reports billing as inactive",
|
||||
});
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
fn billing_status(billing: &InstanceBillingResponse, premium_mode: PremiumMode) -> Markup {
|
||||
let blockers = billing_blockers(billing, premium_mode);
|
||||
html! {
|
||||
div class="space-y-2" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
@if billing.billing_active {
|
||||
(badge("Billing active", BadgeVariant::Success))
|
||||
} @else {
|
||||
(badge("Billing inactive", BadgeVariant::Default))
|
||||
}
|
||||
@match billing.catalog_mode {
|
||||
BillingCatalogMode::Operator => {
|
||||
(badge("Catalog: price table", BadgeVariant::Default))
|
||||
}
|
||||
BillingCatalogMode::Env => {
|
||||
(badge("Catalog: environment", BadgeVariant::Default))
|
||||
}
|
||||
}
|
||||
(secret_badge(
|
||||
"Stripe secret key",
|
||||
billing.stripe_secret_key_set,
|
||||
billing.stripe_secret_key_stored,
|
||||
BadgeVariant::Default,
|
||||
))
|
||||
(secret_badge(
|
||||
"Webhook secret",
|
||||
billing.stripe_webhook_secret_set,
|
||||
billing.stripe_webhook_secret_stored,
|
||||
BadgeVariant::Warning,
|
||||
))
|
||||
}
|
||||
@if !blockers.is_empty() {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Purchases are unavailable because " (blockers.join("; ")) "."
|
||||
}
|
||||
}
|
||||
@if billing.billing_active && !billing.stripe_webhook_secret_set {
|
||||
p class="text-sm text-amber-700" {
|
||||
"Without a webhook secret, Stripe events are rejected, so subscriptions never reach accounts."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_badge(label: &str, is_set: bool, is_stored: bool, missing: BadgeVariant) -> Markup {
|
||||
match (is_set, is_stored) {
|
||||
(_, true) => badge(&format!("{label} set"), BadgeVariant::Success),
|
||||
(true, false) => badge(&format!("{label} from environment"), BadgeVariant::Success),
|
||||
(false, false) => badge(&format!("{label} missing"), missing),
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_field(
|
||||
name: &str,
|
||||
clear_name: &str,
|
||||
label: &str,
|
||||
is_set: bool,
|
||||
is_stored: bool,
|
||||
) -> Markup {
|
||||
let helper = if !is_stored && is_set {
|
||||
"Set from the environment. Enter a value to override it, or leave blank to keep using it."
|
||||
} else {
|
||||
"Leave blank to keep the current value."
|
||||
};
|
||||
html! {
|
||||
div class="flex flex-col gap-2" {
|
||||
(form_field_group(
|
||||
label,
|
||||
name,
|
||||
false,
|
||||
None,
|
||||
Some(helper),
|
||||
html! {
|
||||
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
|
||||
autocomplete="new-password";
|
||||
},
|
||||
))
|
||||
@if is_stored {
|
||||
(checkbox(clear_name, "true", "Clear the stored value", false, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tri_state_value(value: Option<bool>) -> &'static str {
|
||||
match value {
|
||||
None => TRI_STATE_DEFAULT,
|
||||
Some(true) => TRI_STATE_ON,
|
||||
Some(false) => TRI_STATE_OFF,
|
||||
}
|
||||
}
|
||||
|
||||
fn tri_state_select(
|
||||
name: &str,
|
||||
label: &str,
|
||||
default_label: &str,
|
||||
stored: Option<bool>,
|
||||
helper: &str,
|
||||
) -> Markup {
|
||||
let selected = tri_state_value(stored);
|
||||
let options = [
|
||||
(TRI_STATE_DEFAULT, default_label),
|
||||
(TRI_STATE_ON, "On"),
|
||||
(TRI_STATE_OFF, "Off"),
|
||||
];
|
||||
form_field_group(
|
||||
label,
|
||||
name,
|
||||
false,
|
||||
None,
|
||||
Some(helper),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id=(name) name=(name) class=(FORM_SELECT_CLASS) {
|
||||
@for (value, display) in options {
|
||||
option value=(value) selected[value == selected] { (display) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn on_off(value: bool) -> &'static str {
|
||||
if value { "on" } else { "off" }
|
||||
}
|
||||
|
||||
fn checkout_options(billing: &InstanceBillingResponse) -> Markup {
|
||||
let automatic_tax = format!(
|
||||
"Calculates tax at checkout. Needs Stripe Tax activated and a head office address in the Stripe dashboard. Currently {}.",
|
||||
on_off(billing.effective_automatic_tax)
|
||||
);
|
||||
let tax_id = format!(
|
||||
"Lets buyers add a VAT or other tax ID at checkout. Pair it with automatic tax. Currently {}.",
|
||||
on_off(billing.effective_tax_id_collection)
|
||||
);
|
||||
let terms = format!(
|
||||
"Buyers must accept your terms of service at checkout. Needs a terms of service URL in the Stripe dashboard public details. Currently {}.",
|
||||
on_off(billing.effective_terms_consent_required)
|
||||
);
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
h4 class="text-sm font-medium text-neutral-900" { "Checkout options" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
|
||||
(tri_state_select(
|
||||
"billing_automatic_tax",
|
||||
"Automatic tax",
|
||||
"Use default",
|
||||
billing.automatic_tax,
|
||||
&automatic_tax,
|
||||
))
|
||||
(tri_state_select(
|
||||
"billing_tax_id_collection",
|
||||
"Tax ID collection",
|
||||
"Use default",
|
||||
billing.tax_id_collection,
|
||||
&tax_id,
|
||||
))
|
||||
(tri_state_select(
|
||||
"billing_terms_consent_required",
|
||||
"Terms consent",
|
||||
"Use default",
|
||||
billing.terms_consent_required,
|
||||
&terms,
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Members manage and cancel subscriptions in the Stripe customer portal. It only opens after you save its \
|
||||
settings once in the Stripe dashboard under Settings, Billing, Customer portal."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_cell(name: &str, label: &str, value: Option<&str>) -> Markup {
|
||||
html! {
|
||||
td class="px-2 py-2" {
|
||||
input type="text" name=(name) value=(value.unwrap_or(""))
|
||||
placeholder="price_..." aria-label=(label)
|
||||
autocomplete="off" spellcheck="false"
|
||||
class=(FORM_INPUT_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_row(currency: &str, set: &BillingPriceSet) -> Markup {
|
||||
let values = [
|
||||
set.monthly.as_deref(),
|
||||
set.yearly.as_deref(),
|
||||
set.gift_1_month.as_deref(),
|
||||
set.gift_1_year.as_deref(),
|
||||
];
|
||||
html! {
|
||||
tr {
|
||||
td class="px-2 py-2" {
|
||||
input type="text" name="billing_price_currency" value=(currency)
|
||||
placeholder="GBP" maxlength="3" aria-label="Currency"
|
||||
autocomplete="off" spellcheck="false"
|
||||
class={(FORM_INPUT_CLASS) " w-24 uppercase"};
|
||||
}
|
||||
@for ((name, label), value) in PRICE_COLUMNS.iter().zip(values) {
|
||||
(price_cell(name, label, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_table(billing: &InstanceBillingResponse) -> Markup {
|
||||
let empty = BillingPriceSet::default();
|
||||
html! {
|
||||
div class="space-y-2" {
|
||||
h4 class="text-sm font-medium text-neutral-900" { "Prices" }
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One row per currency, using Stripe price IDs from your own account. Monthly and yearly are the \
|
||||
subscription prices; the gift prices are one-time prices for buying gifts. Clear a currency to \
|
||||
remove its row. Leave the table empty to use the prices from environment variables."
|
||||
}
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full text-sm" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
th class="px-2 py-1 font-medium" { "Currency" }
|
||||
@for (_, label) in PRICE_COLUMNS {
|
||||
th class="px-2 py-1 font-medium" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@if let Some(prices) = &billing.prices {
|
||||
@for (currency, set) in prices {
|
||||
(price_row(currency, set))
|
||||
}
|
||||
}
|
||||
(price_row("", &empty))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn country_currencies_text(billing: &InstanceBillingResponse) -> String {
|
||||
billing
|
||||
.country_currencies
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(country, currency)| format!("{country}={currency}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn legacy_prices_text(billing: &InstanceBillingResponse) -> String {
|
||||
billing
|
||||
.legacy_prices
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|(slot, ids)| ids.iter().map(move |id| format!("{slot}={id}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn premium_billing_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
branding: &AppBrandingConfigResponse,
|
||||
billing: &InstanceBillingResponse,
|
||||
premium_mode: PremiumMode,
|
||||
) -> Markup {
|
||||
let enabled_helper = format!(
|
||||
"Use environment setting follows FLUXER_STRIPE_ENABLED or the config file. Billing is currently {}.",
|
||||
on_off(billing.effective_enabled)
|
||||
);
|
||||
section_card_with_description(
|
||||
"Premium & Billing",
|
||||
"Name the premium tier and sell it through your own Stripe account. Subscriptions and gift purchases need \
|
||||
the Mirror premium model, a Stripe secret key and at least one currency with monthly and yearly prices.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_billing"}
|
||||
data-admin-result-form="true" {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-8" {
|
||||
div class="space-y-4" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Premium tier" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"billing_premium_product_name",
|
||||
"Premium name",
|
||||
&branding.premium_product_name,
|
||||
"Premium",
|
||||
))
|
||||
(text_input(
|
||||
"billing_premium_info_url",
|
||||
"Premium info URL",
|
||||
branding.premium_info_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/premium",
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Clients show this name wherever the premium tier is mentioned. Clear it to use the default. \
|
||||
The info URL is an optional page that describes the tier."
|
||||
}
|
||||
@if matches!(premium_mode, PremiumMode::Everyone) {
|
||||
p class="text-sm text-amber-700" {
|
||||
"The premium model is Everyone, so every member already has premium limits and clients hide \
|
||||
premium. Switch the premium model to Mirror to sell subscriptions or redeem gift codes."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Stripe" }
|
||||
(billing_status(billing, premium_mode))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
billing.enabled,
|
||||
&enabled_helper,
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(secret_field(
|
||||
"billing_stripe_secret_key",
|
||||
"billing_clear_stripe_secret_key",
|
||||
"Stripe secret key",
|
||||
billing.stripe_secret_key_set,
|
||||
billing.stripe_secret_key_stored,
|
||||
))
|
||||
(secret_field(
|
||||
"billing_stripe_webhook_secret",
|
||||
"billing_clear_stripe_webhook_secret",
|
||||
"Stripe webhook signing secret",
|
||||
billing.stripe_webhook_secret_set,
|
||||
billing.stripe_webhook_secret_stored,
|
||||
))
|
||||
}
|
||||
(form_field_group(
|
||||
"Webhook URL",
|
||||
"billing_webhook_url",
|
||||
false,
|
||||
None,
|
||||
Some("Add this endpoint in the Stripe dashboard, then paste its signing secret above."),
|
||||
html! {
|
||||
input type="text" id="billing_webhook_url" value=(billing.webhook_url)
|
||||
readonly class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(checkout_options(billing))
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Catalog" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"billing_default_currency",
|
||||
"Default currency",
|
||||
billing.default_currency.as_deref().unwrap_or(""),
|
||||
"GBP",
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Used when a buyer's country has no mapping below. Leave blank to use the first currency in the table."
|
||||
}
|
||||
(price_table(billing))
|
||||
div class="grid grid-cols-1 gap-4 lg:grid-cols-2" {
|
||||
div class="space-y-2" {
|
||||
(textarea_input(
|
||||
"billing_country_currencies",
|
||||
"Country currencies",
|
||||
"SE=SEK\nGB=GBP",
|
||||
&country_currencies_text(billing),
|
||||
6,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One COUNTRY=CURRENCY per line, using 2-letter country codes. Each currency needs a row in the table."
|
||||
}
|
||||
}
|
||||
div class="space-y-2" {
|
||||
(textarea_input(
|
||||
"billing_legacy_prices",
|
||||
"Legacy prices",
|
||||
"monthly_GBP=price_...",
|
||||
&legacy_prices_text(billing),
|
||||
6,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Older price IDs that existing subscribers may still be on, one SLOT_CURRENCY=price ID per line. \
|
||||
Repeat a slot for several IDs. Slots are monthly, yearly, gift_1_month and gift_1_year."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium & billing"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn operator_billing() -> InstanceBillingResponse {
|
||||
InstanceBillingResponse {
|
||||
enabled: Some(true),
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
stripe_secret_key_stored: true,
|
||||
stripe_webhook_secret_stored: true,
|
||||
default_currency: Some("GBP".to_owned()),
|
||||
prices: Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1GbpM".to_owned()),
|
||||
yearly: Some("price_1GbpY".to_owned()),
|
||||
gift_1_month: None,
|
||||
gift_1_year: Some("price_1GbpG".to_owned()),
|
||||
},
|
||||
)])),
|
||||
country_currencies: Some(BTreeMap::from([
|
||||
("GB".to_owned(), "GBP".to_owned()),
|
||||
("IE".to_owned(), "GBP".to_owned()),
|
||||
])),
|
||||
legacy_prices: Some(BTreeMap::from([(
|
||||
"monthly_GBP".to_owned(),
|
||||
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
|
||||
)])),
|
||||
billing_active: true,
|
||||
stripe_serviceable: true,
|
||||
catalog_mode: BillingCatalogMode::Operator,
|
||||
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
|
||||
automatic_tax: None,
|
||||
tax_id_collection: Some(true),
|
||||
terms_consent_required: Some(false),
|
||||
effective_automatic_tax: false,
|
||||
effective_tax_id_collection: true,
|
||||
effective_terms_consent_required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn branding(name: &str) -> AppBrandingConfigResponse {
|
||||
AppBrandingConfigResponse {
|
||||
premium_product_name: name.to_owned(),
|
||||
premium_info_url: Some("https://example.com/gold".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_renders_every_field_and_one_empty_price_row() {
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Gold"),
|
||||
&operator_billing(),
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
|
||||
assert!(markup.contains("data-admin-result-form=\"true\""));
|
||||
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(markup.contains("name=\"billing_automatic_tax\""));
|
||||
assert!(markup.contains("name=\"billing_tax_id_collection\""));
|
||||
assert!(markup.contains("name=\"billing_terms_consent_required\""));
|
||||
assert!(markup.contains("Customer portal"));
|
||||
assert!(markup.contains("name=\"billing_premium_product_name\""));
|
||||
assert!(markup.contains("value=\"Gold\""));
|
||||
assert!(markup.contains("value=\"https://example.com/gold\""));
|
||||
assert!(markup.contains("name=\"billing_enabled\""));
|
||||
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
|
||||
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
|
||||
assert!(markup.contains("Billing active"));
|
||||
assert!(!markup.contains("Purchases are unavailable"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
|
||||
assert_eq!(
|
||||
markup.matches("name=\"billing_price_gift_1_year\"").count(),
|
||||
2
|
||||
);
|
||||
assert!(markup.contains("value=\"price_1GbpG\""));
|
||||
assert!(markup.contains("GB=GBP\nIE=GBP"));
|
||||
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(!markup.contains("sk_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_secrets_have_no_clear_checkbox() {
|
||||
let billing = InstanceBillingResponse::default();
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Everyone,
|
||||
)
|
||||
.into_string();
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
|
||||
assert!(markup.contains("Switch the premium model to Mirror"));
|
||||
assert!(markup.contains("Catalog: environment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_secrets_are_labelled_and_cannot_be_cleared() {
|
||||
let billing = InstanceBillingResponse {
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
..Default::default()
|
||||
};
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("Stripe secret key from environment"));
|
||||
assert!(markup.contains("Webhook secret from environment"));
|
||||
assert!(markup.contains("Set from the environment"));
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_selects_reflect_the_stored_value() {
|
||||
let render = |stored| {
|
||||
tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
stored,
|
||||
"",
|
||||
)
|
||||
.into_string()
|
||||
};
|
||||
assert!(
|
||||
render(None)
|
||||
.contains("<option value=\"default\" selected>Use environment setting</option>")
|
||||
);
|
||||
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockers_explain_why_billing_is_inactive() {
|
||||
let mut billing = InstanceBillingResponse::default();
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Everyone),
|
||||
vec![
|
||||
"the premium model is Everyone, so there is no paid tier to sell",
|
||||
"billing is not enabled",
|
||||
"no Stripe secret key is set",
|
||||
]
|
||||
);
|
||||
billing.effective_enabled = true;
|
||||
billing.stripe_secret_key_set = true;
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec![
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
]
|
||||
);
|
||||
billing.catalog_mode = BillingCatalogMode::Operator;
|
||||
billing.prices = Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1A".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec!["no currency has both a monthly and a yearly price ID"]
|
||||
);
|
||||
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
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,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -23,6 +25,7 @@ use crate::{
|
||||
section_card::{section_card_simple, section_card_with_description},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::instance_billing::premium_billing_section,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
@@ -124,7 +127,25 @@ pub fn instance_config_page(
|
||||
"Community & policy",
|
||||
"Community shape, direct messaging, the premium model, and optional embed services.",
|
||||
html! {
|
||||
(policy_config_section(base, csrf_token, &instance_config.policy))
|
||||
(policy_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Premium & billing",
|
||||
"The premium tier's name, Stripe credentials and the prices members pay.",
|
||||
html! {
|
||||
(premium_billing_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.app_public.branding,
|
||||
&instance_config.billing,
|
||||
instance_config.policy.premium_mode,
|
||||
))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -135,6 +156,13 @@ pub fn instance_config_page(
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Push notifications",
|
||||
"Consent for the relay that delivers official mobile app notifications.",
|
||||
html! {
|
||||
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
@@ -147,7 +175,9 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(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))
|
||||
@@ -196,7 +226,12 @@ fn config_group(title: &str, description: &str, content: Markup) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
fn policy_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
"Control whether this instance runs as a single community, whether direct messages and \
|
||||
@@ -206,7 +241,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
div class="space-y-8" {
|
||||
(single_community_form(base, csrf_token, policy))
|
||||
(direct_messages_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
},
|
||||
@@ -350,7 +385,14 @@ fn deferred_phone_gate_form(
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
fn premium_mode_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
|
||||
@@ -358,8 +400,8 @@ fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyRespon
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_premium_mode", "Premium model", &[
|
||||
("mirror", "Mirror (Free and Premium tiers)"),
|
||||
("everyone", "Everyone (every member gets Plutonium limits)"),
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.as_str()),
|
||||
], policy.premium_mode.as_str()))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium model"))
|
||||
@@ -848,6 +890,18 @@ fn app_public_config_section(
|
||||
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/favicon.ico",
|
||||
))
|
||||
(text_input(
|
||||
"app_status_page_url",
|
||||
"Status page URL",
|
||||
app_public.branding.status_page_url.as_deref().unwrap_or(""),
|
||||
"https://fluxerstatus.com",
|
||||
))
|
||||
(text_input(
|
||||
"app_status_page_incident_history_url",
|
||||
"Status page history URL",
|
||||
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
|
||||
"https://fluxerstatus.com/history",
|
||||
))
|
||||
}
|
||||
div class="space-y-2" {
|
||||
(checkbox(
|
||||
@@ -968,123 +1022,163 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
push_relay: &PushRelayConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
let status = if push_relay.relay_consent_accepted {
|
||||
("Accepted", BadgeVariant::Success)
|
||||
} else {
|
||||
("Not accepted", BadgeVariant::Default)
|
||||
};
|
||||
let accepted_at =
|
||||
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||
let accepted_by = push_relay
|
||||
.relay_consent_accepted_by
|
||||
.as_deref()
|
||||
.unwrap_or("Nobody");
|
||||
section_card_with_description(
|
||||
"Push Relay",
|
||||
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
||||
The relay delivers them only after an operator accepts its privacy notice.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||
(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" { "Relay consent" }
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
(checkbox(
|
||||
"push_relay_consent_accepted",
|
||||
"true",
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_relay.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Until this is accepted official mobile app notifications are dropped. \
|
||||
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
|
||||
unaffected. "
|
||||
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Read the notice"
|
||||
}
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_at"
|
||||
value=(accepted_at)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_by"
|
||||
value=(accepted_by)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Push Relay Settings"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn domain_migration_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
domain_migration: &DomainMigrationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if domain_migration.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let backend_labels =
|
||||
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||
let backend_options = backend_labels
|
||||
.iter()
|
||||
.map(|(value, label)| (value.as_str(), *label))
|
||||
.collect::<Vec<_>>();
|
||||
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||
let guild_overrides = voice_noise_suppression
|
||||
.guild_overrides
|
||||
.iter()
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
||||
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Voice Noise Suppression",
|
||||
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||
these fields say.",
|
||||
"Domain Migration",
|
||||
"Moves web clients of the official instance from the legacy web app origin to the new \
|
||||
one. Selected accounts copy their local data across and continue on the new origin. \
|
||||
Clients of other instances read this configuration and ignore it.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
||||
(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 " (voice_noise_suppression.config_version)
|
||||
"Config version " (domain_migration.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"domain_migration_enabled",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
"Move selected web clients to the new origin",
|
||||
domain_migration.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||
and override fields below have no effect at all."
|
||||
"Off is the safe state and the kill switch. With this unchecked no client \
|
||||
starts a migration and clients that already migrated stop forwarding the \
|
||||
legacy origin, so the rollout and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||
(select_input(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
&voice_noise_suppression.default_backend.to_string(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The backend assigned by always-on user rules and the canary. A default \
|
||||
that is not ticked below is unavailable, but per-guild overrides can \
|
||||
still target users."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||
@for backend in NoiseSuppressionBackend::ALL {
|
||||
(checkbox(
|
||||
"voice_ns_enabled_backends[]",
|
||||
&backend.to_string(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||
every user, including anyone who picked it themselves."
|
||||
}
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
||||
(checkbox(
|
||||
"voice_ns_allow_user_override",
|
||||
"domain_migration_standalone_forwarding",
|
||||
"true",
|
||||
"Let users pick their own backend from the ticked list",
|
||||
voice_noise_suppression.allow_user_override,
|
||||
"Forward installed desktop web apps to the new origin",
|
||||
domain_migration.standalone_forwarding,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Applies only to users who are already targeted. It never pulls anyone \
|
||||
into the rollout."
|
||||
"Leave this off until the manifest scope extension and the association file \
|
||||
are live and verified. While it is off, installed Chromium desktop apps copy \
|
||||
their data across but stay on the legacy origin and offer to install the new \
|
||||
app. Installed mobile and Safari apps never forward either way."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||
&domain_migration.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(number_field(
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout (basis points)",
|
||||
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_ns_rollout_salt",
|
||||
"domain_migration_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_noise_suppression.rollout_salt,
|
||||
"voice-ns-v1",
|
||||
&domain_migration.rollout_salt,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
||||
which users and devices fall inside the percentages above. Leave it \
|
||||
alone to keep the current cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_user_ids",
|
||||
"domain_migration_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
@@ -1092,19 +1186,51 @@ fn voice_noise_suppression_section(
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
domain_migration.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"domain_migration_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
domain_migration.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(
|
||||
"voice_ns_excluded_user_ids",
|
||||
"domain_migration_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&domain_migration.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.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(
|
||||
"domain_migration_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
@@ -1112,62 +1238,326 @@ fn voice_noise_suppression_section(
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.excluded_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
domain_migration.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, so this is the per-user kill switch."
|
||||
percentage. It stops new migrations only. A user who already moved \
|
||||
stays on the new origin."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Domain Migration Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
"voice_ns_guild_overrides",
|
||||
"Guild Overrides",
|
||||
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||
&guild_overrides,
|
||||
"altcha_captcha_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.guild_overrides.len(),
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
altcha_captcha.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One per line as guild_id=backend. A guild \
|
||||
rule targets callers even outside the canary. Always-on user rules \
|
||||
take precedence, and excluded users stay off. Invalid lines and \
|
||||
conflicting rules for the same guild prevent the save. \
|
||||
Unticked backends stay stored but are inactive."
|
||||
"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" { "Processing" }
|
||||
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 profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
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.",
|
||||
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(
|
||||
"voice_ns_stereo_enabled",
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Process stereo input instead of downmixing to mono",
|
||||
voice_noise_suppression.stereo_enabled,
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Costs more CPU on the client. Leave off unless you are testing stereo \
|
||||
capture."
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression Strength",
|
||||
&voice_noise_suppression.suppression_strength.to_string(),
|
||||
Some(0), Some(100), "1",
|
||||
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||
|
||||
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" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.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(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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(
|
||||
"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 Voice Noise Suppression Configuration"))
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1183,7 +1573,7 @@ fn experiment_delivery_section(
|
||||
section_card_with_description(
|
||||
"Experiment Delivery",
|
||||
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||
and covers every experiment, not just the one above. Raising the interval sheds \
|
||||
and covers every experiment, not just the ones above. Raising the interval sheds \
|
||||
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||
html! {
|
||||
@@ -1767,7 +2157,7 @@ fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) ->
|
||||
|
||||
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
|
||||
let description = if limit_config.self_hosted.unwrap_or(false) {
|
||||
"Self-hosted instance with all premium features enabled. Configure user and guild limits."
|
||||
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
|
||||
} else {
|
||||
"Configure limit rules that control user and guild restrictions based on traits and features."
|
||||
};
|
||||
@@ -1789,44 +2179,73 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||
|
||||
fn rendered_voice_noise_suppression_section(
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> String {
|
||||
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
}],
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(markup.contains("1 of 200 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
};
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_mode_options_use_the_configured_premium_name() {
|
||||
let markup =
|
||||
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
|
||||
.into_string();
|
||||
assert!(markup.contains("Mirror (Free and Gold tiers)"));
|
||||
assert!(markup.contains("Everyone (every member gets Gold limits)"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_flags_a_list_at_its_cap() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod gift_codes;
|
||||
pub mod guild_detail;
|
||||
pub mod guild_detail_tabs;
|
||||
pub mod guilds_list;
|
||||
pub mod instance_billing;
|
||||
pub mod instance_config;
|
||||
pub mod job_detail;
|
||||
pub mod jobs_list;
|
||||
|
||||
@@ -38,11 +38,22 @@ pub fn user_detail_page(
|
||||
auth: &AuthContext,
|
||||
user: Option<&AdminUser>,
|
||||
user_id: &str,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
user_detail_with_tab(config, auth, user, user_id, "overview", None, is_htmx)
|
||||
user_detail_with_tab(
|
||||
config,
|
||||
auth,
|
||||
user,
|
||||
user_id,
|
||||
"overview",
|
||||
None,
|
||||
premium_badge_name,
|
||||
is_htmx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn user_detail_with_tab(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -50,10 +61,13 @@ pub fn user_detail_with_tab(
|
||||
user_id: &str,
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let content = match user {
|
||||
Some(user) => render_user_detail(config, auth, user, active_tab, tab_body),
|
||||
Some(user) => {
|
||||
render_user_detail(config, auth, user, active_tab, tab_body, premium_badge_name)
|
||||
}
|
||||
None => not_found_state("User", user_id, None, None),
|
||||
};
|
||||
let title = user
|
||||
@@ -79,6 +93,7 @@ fn render_user_detail(
|
||||
user: &AdminUser,
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let display_name = user
|
||||
.global_name
|
||||
@@ -143,6 +158,7 @@ fn render_user_detail(
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ fn status_badge(user: &AdminUser) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[String]) -> Markup {
|
||||
pub fn user_peek_fragment(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
admin_acls: &[String],
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let display = user
|
||||
@@ -62,6 +67,7 @@ pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ impl UserListParams {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn users_list_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -102,10 +103,18 @@ pub fn users_list_page(
|
||||
results: Option<&[AdminUser]>,
|
||||
has_more: bool,
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let results_markup = render_results(config, params, results, has_more, can_view_email);
|
||||
let results_markup = render_results(
|
||||
config,
|
||||
params,
|
||||
results,
|
||||
has_more,
|
||||
can_view_email,
|
||||
premium_badge_name,
|
||||
);
|
||||
|
||||
if is_htmx {
|
||||
return results_markup;
|
||||
@@ -114,7 +123,10 @@ pub fn users_list_page(
|
||||
let content = html! {
|
||||
div class="space-y-6" {
|
||||
(page_header("Users", None))
|
||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
|
||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
|
||||
p class="mb-1 text-xs text-neutral-500" {
|
||||
"For example, type " span class="font-mono" { "*" } " in to search for all users."
|
||||
}
|
||||
(search_form(base, params))
|
||||
}
|
||||
(results_markup)
|
||||
@@ -212,6 +224,7 @@ fn render_results(
|
||||
results: Option<&[AdminUser]>,
|
||||
page_has_more: bool,
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@@ -233,7 +246,7 @@ fn render_results(
|
||||
"Copy IDs"
|
||||
}
|
||||
}
|
||||
(render_users_table(config, users, can_view_email))
|
||||
(render_users_table(config, users, can_view_email, premium_badge_name))
|
||||
script { (maud::PreEscaped(copy_ids_script())) }
|
||||
@if !params.has_id_lookup() && (params.page > 0 || page_has_more) {
|
||||
(pagination_controls(base, params, page_has_more))
|
||||
@@ -298,7 +311,12 @@ fn user_status_badge(user: &AdminUser) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: bool) -> Markup {
|
||||
fn render_users_table(
|
||||
config: &AdminConfig,
|
||||
users: &[AdminUser],
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
table_container(html! {
|
||||
table class="min-w-full divide-y divide-neutral-200" {
|
||||
@@ -340,6 +358,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ impl MultiValueForm {
|
||||
self.fields.contains_key(key)
|
||||
}
|
||||
|
||||
pub fn values(&self, key: &str) -> &[String] {
|
||||
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn first(&self, key: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.get(key)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_admin::api::generated::types as generated_types;
|
||||
use fluxer_admin::api::types;
|
||||
|
||||
#[test]
|
||||
@@ -229,6 +230,7 @@ fn deserialize_audit_logs_response() {
|
||||
"target_type": "user",
|
||||
"target_id": "1130958221824557056",
|
||||
"action": "list_user_sessions",
|
||||
"access": "read",
|
||||
"audit_log_reason": null,
|
||||
"metadata": {"session_count": "3"},
|
||||
"created_at": "2026-05-26T13:21:47.138Z"
|
||||
@@ -242,11 +244,481 @@ fn deserialize_audit_logs_response() {
|
||||
assert_eq!(resp.logs.len(), 1);
|
||||
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
|
||||
assert_eq!(resp.logs[0].action, "list_user_sessions");
|
||||
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
|
||||
assert_eq!(resp.logs[0].target_type, "user");
|
||||
assert!(resp.logs[0].audit_log_reason.is_none());
|
||||
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_guild_audit_logs_with_unknown_keys() {
|
||||
let json = r#"{
|
||||
"audit_log_entries": [
|
||||
{
|
||||
"id": "1508822460457747581",
|
||||
"action_type": 22,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557056",
|
||||
"reason": "spam",
|
||||
"options": {
|
||||
"delete_message_seconds": 3600,
|
||||
"future_option": {"nested": true}
|
||||
},
|
||||
"changes": [
|
||||
{"key": "future_change", "future_field": 1}
|
||||
],
|
||||
"future_entry_field": "ignored"
|
||||
},
|
||||
{
|
||||
"id": "1508822460457747580",
|
||||
"action_type": 31,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557057",
|
||||
"options": {
|
||||
"id": "1130958221824557057",
|
||||
"type": 0,
|
||||
"channel_id": "1130958221824557058",
|
||||
"role_name": "Moderators"
|
||||
},
|
||||
"changes": [
|
||||
{"key": "permissions_diff", "new_value": {"added": ["SEND_MESSAGES"], "removed": []}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1508822460457747579",
|
||||
"action_type": 11,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557059",
|
||||
"options": {"type": 998},
|
||||
"changes": [
|
||||
{"key": "name", "old_value": "old-name", "new_value": "new-name"},
|
||||
{"key": "position", "old_value": 3, "new_value": 4},
|
||||
{"key": "nsfw", "new_value": true},
|
||||
{"key": "roles", "new_value": ["1130958221824557060", "1130958221824557061"]}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [],
|
||||
"webhooks": []
|
||||
}"#;
|
||||
|
||||
let resp: generated_types::ListGuildAuditLogsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.audit_log_entries.len(), 3);
|
||||
|
||||
let ban = &resp.audit_log_entries[0];
|
||||
assert_eq!(*ban.action_type, 22);
|
||||
assert_eq!(ban.reason.as_deref(), Some("spam"));
|
||||
assert!(ban.options.is_some());
|
||||
assert_eq!(ban.changes.len(), 1);
|
||||
assert_eq!(ban.changes[0].key, "future_change");
|
||||
assert!(ban.changes[0].new_value.is_none());
|
||||
|
||||
let role_update = &resp.audit_log_entries[1];
|
||||
let options = role_update.options.as_ref().unwrap();
|
||||
assert_eq!(options.role_name.as_deref(), Some("Moderators"));
|
||||
assert_eq!(options.channel_id.as_deref(), Some("1130958221824557058"));
|
||||
assert_eq!(options.type_, Some(0.0));
|
||||
assert_eq!(role_update.changes[0].key, "permissions_diff");
|
||||
assert!(role_update.changes[0].new_value.is_some());
|
||||
|
||||
let channel_update = &resp.audit_log_entries[2];
|
||||
assert_eq!(channel_update.changes.len(), 4);
|
||||
assert_eq!(
|
||||
channel_update.changes[0]
|
||||
.old_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("old-name")
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[0]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("new-name")
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[1]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_i64()),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[2]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[3]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|value| value.len()),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
let json = r##"{
|
||||
"sso": {
|
||||
"enabled": true,
|
||||
"enforced": false,
|
||||
"display_name": "Fluxer SSO",
|
||||
"issuer": "https://id.example.com",
|
||||
"authorization_url": "https://id.example.com/authorize",
|
||||
"token_url": "https://id.example.com/token",
|
||||
"userinfo_url": "https://id.example.com/userinfo",
|
||||
"jwks_url": "https://id.example.com/jwks.json",
|
||||
"client_id": "fluxer-admin",
|
||||
"client_secret_set": true,
|
||||
"scope": "openid profile email",
|
||||
"allowed_domains": ["fluxer.com"],
|
||||
"auto_provision": true,
|
||||
"redirect_uri": "https://fluxer.com/sso/callback"
|
||||
},
|
||||
"gateway_rollout": {
|
||||
"session_rollout_percentage": 100,
|
||||
"session_rollout_mode": "modulo",
|
||||
"guild_rollout_percentage": 100,
|
||||
"rpc_request_timeout_ms": 10000,
|
||||
"max_concurrent_session_starts": 512,
|
||||
"max_concurrent_guild_starts": 256,
|
||||
"gateway_dispatch_relay_shards": 32,
|
||||
"gateway_dispatch_relay_max_queue": 50000,
|
||||
"voice_e2ee_scope": "guild_feature_only",
|
||||
"future_rollout_knob": 3
|
||||
},
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_migration_knob": 9,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"],
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"altcha_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
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
"urls": [],
|
||||
"pending_registrations": []
|
||||
},
|
||||
"self_hosted": false,
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"product_name": "Fluxer",
|
||||
"icon_url": "https://cdn.example.com/icon.png",
|
||||
"symbol_url": "https://cdn.example.com/symbol.svg",
|
||||
"logo_url": "https://cdn.example.com/logo.svg",
|
||||
"wordmark_url": "https://cdn.example.com/wordmark.svg",
|
||||
"favicon_url": "https://cdn.example.com/favicon.ico",
|
||||
"theme_color": "#5865f2",
|
||||
"future_asset_url": "https://cdn.example.com/future.png",
|
||||
"premium_product_name": "Gold",
|
||||
"premium_info_url": "https://example.com/gold"
|
||||
},
|
||||
"setup": {"configured": true},
|
||||
"legal": {
|
||||
"terms_url": "https://fluxer.com/terms",
|
||||
"privacy_url": "https://fluxer.com/privacy"
|
||||
},
|
||||
"registration": {"collect_date_of_birth": true}
|
||||
},
|
||||
"policy": {
|
||||
"single_community_enabled": false,
|
||||
"single_community_guild_id": null,
|
||||
"direct_messages_disabled": false,
|
||||
"direct_messages_locked": false,
|
||||
"premium_mode": "mirror",
|
||||
"services": {
|
||||
"gif_enabled": true,
|
||||
"youtube_enabled": true,
|
||||
"bluesky_enabled": false,
|
||||
"future_service_enabled": true
|
||||
},
|
||||
"services_resolved": {
|
||||
"gif_enabled": true,
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"provider": "smtp",
|
||||
"effective_provider": "smtp",
|
||||
"from_email": "[email protected]",
|
||||
"from_name": "Fluxer",
|
||||
"smtp": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "fluxer",
|
||||
"password_set": true,
|
||||
"secure": true
|
||||
},
|
||||
"disable_new_ip_authorization": false,
|
||||
"effective_disable_new_ip_authorization": false
|
||||
},
|
||||
"bluesky": {
|
||||
"enabled": false,
|
||||
"effective_enabled": false,
|
||||
"client_name": "Fluxer",
|
||||
"client_uri": "https://fluxer.com",
|
||||
"logo_uri": "https://cdn.example.com/logo.svg",
|
||||
"tos_uri": "https://fluxer.com/terms",
|
||||
"policy_uri": "https://fluxer.com/privacy",
|
||||
"key_count": 0
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"attachment_decay": {
|
||||
"enabled": true,
|
||||
"min_size_mb": 10,
|
||||
"max_size_mb": 500,
|
||||
"max_eligible_size_mb": 500,
|
||||
"min_lifetime_days": 30,
|
||||
"max_lifetime_days": 365,
|
||||
"curve": 2,
|
||||
"renew_threshold_days": 7,
|
||||
"renew_window_days": 14,
|
||||
"effective": {
|
||||
"enabled": true,
|
||||
"min_size_mb": 10,
|
||||
"max_size_mb": 500,
|
||||
"max_eligible_size_mb": 500,
|
||||
"min_lifetime_days": 30,
|
||||
"max_lifetime_days": 365,
|
||||
"curve": 2,
|
||||
"renew_threshold_days": 7,
|
||||
"renew_window_days": 14,
|
||||
"future_curve": 1.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
"stripe_secret_key_set": true,
|
||||
"stripe_webhook_secret_set": false,
|
||||
"stripe_secret_key_stored": true,
|
||||
"stripe_webhook_secret_stored": false,
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false,
|
||||
"effective_automatic_tax": false,
|
||||
"effective_tax_id_collection": true,
|
||||
"effective_terms_consent_required": false,
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1GbpM",
|
||||
"yearly": "price_1GbpY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": "price_1GbpG"
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP"},
|
||||
"legacy_prices": {"monthly_GBP": ["price_1OldA"]},
|
||||
"billing_active": true,
|
||||
"stripe_serviceable": true,
|
||||
"catalog_mode": "operator",
|
||||
"webhook_url": "https://api.example.com/stripe/webhook",
|
||||
"future_billing_knob": 1
|
||||
},
|
||||
"future_section": {"enabled": true, "rollout_basis_points": 10000},
|
||||
"future_flag": 3
|
||||
}"##;
|
||||
|
||||
let resp: generated_types::InstanceConfigResponse = serde_json::from_str(json).expect(
|
||||
"the build.rs relaxation must keep unknown response keys from failing the whole response",
|
||||
);
|
||||
|
||||
assert!(!resp.self_hosted);
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
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_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));
|
||||
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
|
||||
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(resp.billing.billing_active);
|
||||
assert!(resp.media.attachment_decay.effective.enabled);
|
||||
|
||||
let ours: types::InstanceConfigResponse =
|
||||
serde_json::from_str(json).expect("hand-written instance config");
|
||||
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(ours.billing.stripe_secret_key_stored);
|
||||
assert_eq!(ours.billing.tax_id_collection, Some(true));
|
||||
assert!(ours.billing.effective_tax_id_collection);
|
||||
assert_eq!(
|
||||
ours.app_public.branding.premium_info_url.as_deref(),
|
||||
Some("https://example.com/gold")
|
||||
);
|
||||
assert!(ours.billing.billing_active);
|
||||
assert!(ours.billing.stripe_serviceable);
|
||||
assert!(!ours.billing.stripe_webhook_secret_set);
|
||||
assert_eq!(
|
||||
ours.billing.catalog_mode,
|
||||
types::BillingCatalogMode::Operator
|
||||
);
|
||||
assert_eq!(ours.billing.default_currency.as_deref(), Some("GBP"));
|
||||
let gbp = &ours.billing.prices.as_ref().expect("prices")["GBP"];
|
||||
assert_eq!(gbp.gift_1_year.as_deref(), Some("price_1GbpG"));
|
||||
assert_eq!(gbp.gift_1_month, None);
|
||||
assert_eq!(
|
||||
ours.billing.legacy_prices.as_ref().expect("legacy")["monthly_GBP"],
|
||||
vec!["price_1OldA".to_owned()]
|
||||
);
|
||||
|
||||
let without_unknown_keys = json
|
||||
.replace("\"future_rollout_knob\": 3,", "")
|
||||
.replace("\"future_presentation_knob\": \"verbose\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_migration_knob\": 9,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
"\"future_asset_url\": \"https://cdn.example.com/future.png\",",
|
||||
"",
|
||||
)
|
||||
.replace("\"future_service_enabled\": true,", "")
|
||||
.replace("\"future_curve\": 1.5,", "")
|
||||
.replace(",\n \"future_billing_knob\": 1", "")
|
||||
.replace(
|
||||
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
|
||||
"",
|
||||
)
|
||||
.replace("\"future_flag\": 3,", "");
|
||||
let baseline: generated_types::InstanceConfigResponse =
|
||||
serde_json::from_str(&without_unknown_keys).expect("known keys alone still deserialize");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&baseline).unwrap(),
|
||||
serde_json::to_value(&resp).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_relay_config() {
|
||||
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
}"#,
|
||||
)
|
||||
.expect("an accepted relay consent must deserialize");
|
||||
|
||||
assert!(accepted.relay_consent_accepted);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_at.as_deref(),
|
||||
Some("2026-09-27T10:11:12.000Z")
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_by.as_deref(),
|
||||
Some("1130650140672000000")
|
||||
);
|
||||
|
||||
let empty: types::PushRelayConfigResponse =
|
||||
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
|
||||
|
||||
assert!(!empty.relay_consent_accepted);
|
||||
assert!(empty.relay_consent_accepted_at.is_none());
|
||||
assert!(empty.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_relay_update_omits_an_unset_consent() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
|
||||
let with = types::PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&with).unwrap(),
|
||||
serde_json::json!({"relay_consent_accepted": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
@@ -521,7 +993,8 @@ fn deserialize_webauthn_credentials_response() {
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
|
||||
@@ -168,6 +168,39 @@ async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_audit_log_tabs_request_write_entries_only() {
|
||||
let app = setup().await;
|
||||
for path in [
|
||||
"/users/1500000000000000001/tabs/audit_logs",
|
||||
"/guilds/1600000000000000001/tabs/audit_logs",
|
||||
] {
|
||||
let fragment = get(&app, path, &[]).await;
|
||||
assert!(fragment.contains("Temp ban"), "{path}\n{fragment}");
|
||||
assert!(!fragment.contains("Get user"), "{path}\n{fragment}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_log_page_forwards_the_access_filter() {
|
||||
let app = setup().await;
|
||||
let all = get(&app, "/audit-logs", &[]).await;
|
||||
assert!(all.contains("Temp ban"), "{all}");
|
||||
assert!(all.contains("Get user"), "{all}");
|
||||
assert!(
|
||||
all.contains(r#"<option value="" selected>All entries</option>"#),
|
||||
"{all}"
|
||||
);
|
||||
|
||||
let reads = get(&app, "/audit-logs?access=read", &[]).await;
|
||||
assert!(reads.contains("Get user"), "{reads}");
|
||||
assert!(!reads.contains("Temp ban"), "{reads}");
|
||||
assert!(
|
||||
reads.contains(r#"<option value="read" selected>Reads only</option>"#),
|
||||
"{reads}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_routes_keep_layout_and_fragment_contract() {
|
||||
let app = setup().await;
|
||||
@@ -431,7 +464,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
&[
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -783,6 +816,9 @@ async fn spawn_mock_api() -> String {
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method == Method::PATCH && path == "/admin/instance/config" {
|
||||
return json_response(instance_config());
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
|
||||
(Method::GET, "/admin/api-keys") => json_response(json!([])),
|
||||
@@ -844,6 +880,25 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
|
||||
(Method::GET, "/admin/audit-logs") => {
|
||||
let access = uri.query().and_then(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(key, _)| key == "access")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
});
|
||||
let logs = [
|
||||
audit_log_entry("1900000000000000101", "get_user", "read"),
|
||||
audit_log_entry("1900000000000000102", "temp_ban", "write"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
access
|
||||
.as_deref()
|
||||
.is_none_or(|access| entry.access.to_string() == access)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json_response(json!({ "total": logs.len(), "logs": logs }))
|
||||
}
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -975,6 +1030,32 @@ fn guild_fixtures_match_generated_response_contracts() {
|
||||
assert_eq!(detail.member_count, 12);
|
||||
}
|
||||
|
||||
fn audit_log_entry(
|
||||
log_id: &str,
|
||||
action: &str,
|
||||
access: &str,
|
||||
) -> generated_types::AdminAuditLogResponseSchema {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1500000000000000000",
|
||||
"admin_user": null,
|
||||
"target_type": "user",
|
||||
"target_id": "1500000000000000001",
|
||||
"target_user": null,
|
||||
"target_guild": null,
|
||||
"target_channel": null,
|
||||
"related_users": {},
|
||||
"related_guilds": {},
|
||||
"related_channels": {},
|
||||
"action": action,
|
||||
"access": access,
|
||||
"audit_log_reason": null,
|
||||
"metadata": {},
|
||||
"created_at": "2026-09-16T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_application() -> Value {
|
||||
json!({
|
||||
"id": "1700000000000000001",
|
||||
@@ -1097,27 +1178,15 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
|
||||
+8
-12
@@ -1,11 +1,11 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
ARG BUILD_VERSION
|
||||
|
||||
FROM node:24-bookworm-slim AS base
|
||||
FROM node:26-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
FROM base AS deploy
|
||||
|
||||
@@ -23,9 +23,9 @@ COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter fluxer_api run build
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allow-unused-patches=true /out
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
FROM node:26-trixie-slim
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG SOURCE_SHA
|
||||
@@ -45,32 +45,28 @@ LABEL app.fluxer.build-version="${BUILD_VERSION}"
|
||||
|
||||
WORKDIR /usr/src/app/fluxer_api
|
||||
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/sources.list.d/backports.list && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
libimage-exiftool-perl \
|
||||
libwebp7 \
|
||||
libwebpmux3 \
|
||||
libheif1 \
|
||||
libvips42 && \
|
||||
apt-get install -y --no-install-recommends -t bookworm-backports \
|
||||
libheif-plugin-libde265 \
|
||||
libheif-plugin-dav1d && \
|
||||
libheif-plugin-dav1d \
|
||||
libvips42t64 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
COPY --from=deploy /out ./
|
||||
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
|
||||
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
|
||||
|
||||
RUN rm -rf pkgs && \
|
||||
mkdir -p /usr/src/app/.cache/corepack && \
|
||||
chown -R 65532:65532 /usr/src/app
|
||||
|
||||
ENV HOME=/usr/src/app
|
||||
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS="--enable-source-maps"
|
||||
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
|
||||
"start": "tsx src/AppEntrypoint.ts",
|
||||
"start:worker": "tsx src/WorkerEntrypoint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "catalog:",
|
||||
"@atproto/jwk-jose": "catalog:",
|
||||
"@atproto/oauth-client-node": "catalog:",
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
"@aws-sdk/lib-storage": "catalog:",
|
||||
"@aws-sdk/s3-request-presigner": "catalog:",
|
||||
"@bluesky-social/jwk-jose": "catalog:",
|
||||
"@bluesky-social/oauth-client-node": "catalog:",
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@bufbuild/protobuf": "^2.15.0",
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
"@fluxer/config": "workspace:*",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -34,6 +34,8 @@
|
||||
"@hono/node-server": "catalog:",
|
||||
"@messageformat/core": "catalog:",
|
||||
"@messageformat/parser": "catalog:",
|
||||
"@nats-io/jetstream": "catalog:",
|
||||
"@nats-io/transport-node": "catalog:",
|
||||
"@pkgs/cache": "workspace:*",
|
||||
"@pkgs/captcha": "workspace:*",
|
||||
"@pkgs/cassandra": "workspace:*",
|
||||
@@ -54,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
@@ -71,7 +74,6 @@
|
||||
"lodash": "catalog:",
|
||||
"maxmind": "catalog:",
|
||||
"mime": "catalog:",
|
||||
"nats": "catalog:",
|
||||
"nodemailer": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"pino": "catalog:",
|
||||
@@ -88,10 +90,10 @@
|
||||
"devDependencies": {
|
||||
"@types/archiver": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"msw": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -9,14 +9,14 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pkgs/kv_client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
"altcha-lib": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
|
||||
const AltchaPayloadSchema = z.object({
|
||||
challenge: z.object({
|
||||
parameters: z.looseObject({
|
||||
algorithm: z.literal(ALTCHA_ALGORITHM),
|
||||
nonce: z.string().regex(HEX_PATTERN),
|
||||
salt: z.string().regex(HEX_PATTERN),
|
||||
cost: z.number().int().positive(),
|
||||
keyLength: z.number().int().positive(),
|
||||
keyPrefix: z.string().regex(HEX_PATTERN),
|
||||
keySignature: z.string().regex(HEX_PATTERN),
|
||||
expiresAt: z.number().int().positive(),
|
||||
}),
|
||||
signature: z.string().regex(HEX_PATTERN),
|
||||
}),
|
||||
solution: z.object({
|
||||
counter: z.number().int().min(0),
|
||||
derivedKey: z.string().regex(HEX_PATTERN),
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
|
||||
|
||||
export interface AltchaProviderOptions {
|
||||
hmacSignatureSecret: string;
|
||||
hmacKeySignatureSecret: string;
|
||||
cost: number;
|
||||
maxCounter: number;
|
||||
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
|
||||
logger?: LoggerInterface;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function decodePayload(token: string): AltchaPayload | null {
|
||||
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
|
||||
try {
|
||||
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: AltchaProviderOptions) {
|
||||
this.options = options;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
|
||||
return await createChallenge({
|
||||
algorithm: ALTCHA_ALGORITHM,
|
||||
cost,
|
||||
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
|
||||
deriveKey,
|
||||
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret,
|
||||
hmacKeySignatureSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
const result = await verifySolution({
|
||||
challenge: payload.challenge,
|
||||
solution: payload.solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: this.options.hmacSignatureSecret,
|
||||
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
|
||||
});
|
||||
if (!result.verified) {
|
||||
this.options.logger?.warn(
|
||||
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
|
||||
'ALTCHA verification failed',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
|
||||
return false;
|
||||
}
|
||||
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
|
||||
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"cassandra-driver": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
@@ -15,6 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter, esTermsFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
@@ -15,6 +15,10 @@ function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<Elasticsear
|
||||
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
|
||||
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
|
||||
if (filters.action) clauses.push(esTermFilter('action', filters.action));
|
||||
if (filters.actions && filters.actions.length > 0) clauses.push(esTermsFilter('action.keyword', filters.actions));
|
||||
if (filters.excludeActions && filters.excludeActions.length > 0) {
|
||||
clauses.push({bool: {must_not: [esTermsFilter('action.keyword', filters.excludeActions)]}});
|
||||
}
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esTermFilter} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esTermFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildGuildFilters(filters: GuildSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
+11
-5
@@ -6,11 +6,17 @@ import type {
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esRangeFilter, esTermFilter, esTermsFilter} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildGuildMemberFilters(filters: GuildMemberSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
@@ -11,9 +11,9 @@ import type {
|
||||
SortResults,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters} from '../ElasticsearchFilterUtils';
|
||||
import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import type {ElasticsearchIndexDefinition} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
||||
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
||||
|
||||
@@ -4,11 +4,17 @@ import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esExcludeAny, esTermFilter, esTermsFilter} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esExcludeAny,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
const HAS_FIELD_MAP: Record<string, string> = {
|
||||
image: 'hasImage',
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters, esExistsFilter, esNotExistsFilter, esTermFilter} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esExistsFilter,
|
||||
esNotExistsFilter,
|
||||
esTermFilter,
|
||||
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildReportFilters(filters: ReportSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
@@ -11,10 +13,8 @@ import {
|
||||
esNotExistsFilter,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
} from '../ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
|
||||
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
|
||||
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildUserFilters(filters: UserSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/i18n": "workspace:*",
|
||||
@@ -19,8 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/nodemailer": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const logger = createLogger('email-service');
|
||||
const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
function formatMinorUnitAmount(amountMinor: number, currency: string, locale: string | null): string {
|
||||
const formatter = new Intl.NumberFormat(locale || DEFAULT_LOCALE, {style: 'currency', currency});
|
||||
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
||||
return formatter.format(amountMinor / 10 ** fractionDigits);
|
||||
}
|
||||
|
||||
export class EmailService implements IEmailService {
|
||||
private readonly config: EmailConfig;
|
||||
@@ -127,7 +134,7 @@ export class EmailService implements IEmailService {
|
||||
async sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string,
|
||||
reason: string | null,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'unban_notification', locale, {username, reason});
|
||||
@@ -301,7 +308,7 @@ export class EmailService implements IEmailService {
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'donation_confirmation', locale, {
|
||||
amount: (amountCents / 100).toFixed(2),
|
||||
amount: formatMinorUnitAmount(amountCents, currency, locale),
|
||||
currency: currency.toUpperCase(),
|
||||
interval,
|
||||
manageUrl,
|
||||
|
||||
@@ -43,7 +43,12 @@ export interface IEmailService {
|
||||
deletionDate: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendUnbanNotification(email: string, username: string, reason: string, locale?: string | null): Promise<boolean>;
|
||||
sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendScheduledDeletionNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import nodemailer from 'nodemailer';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
|
||||
const logger = createLogger('@pkgs/email/src/SmtpEmailProvider');
|
||||
|
||||
@@ -18,7 +18,7 @@ interface SmtpEmailConfig {
|
||||
}
|
||||
|
||||
export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly transporter: nodemailer.Transporter;
|
||||
private readonly transporter: Transporter;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
|
||||
@@ -126,11 +126,11 @@ export class TestEmailService implements ITestEmailService {
|
||||
async sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string,
|
||||
reason: string | null,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason}`);
|
||||
return this.record(email, 'unban_notification', {reason});
|
||||
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason ?? 'none'}`);
|
||||
return this.record(email, 'unban_notification', {reason: reason ?? ''});
|
||||
}
|
||||
|
||||
async sendScheduledDeletionNotification(
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
import EMAIL_I18N_AR_MESSAGES from './locales/ar';
|
||||
import EMAIL_I18N_BG_MESSAGES from './locales/bg';
|
||||
import EMAIL_I18N_CS_MESSAGES from './locales/cs';
|
||||
import EMAIL_I18N_DA_MESSAGES from './locales/da';
|
||||
import EMAIL_I18N_DE_MESSAGES from './locales/de';
|
||||
import EMAIL_I18N_EL_MESSAGES from './locales/el';
|
||||
import EMAIL_I18N_EN_GB_MESSAGES from './locales/en-GB';
|
||||
import EMAIL_I18N_ES_419_MESSAGES from './locales/es-419';
|
||||
import EMAIL_I18N_ES_ES_MESSAGES from './locales/es-ES';
|
||||
import EMAIL_I18N_FI_MESSAGES from './locales/fi';
|
||||
import EMAIL_I18N_FR_MESSAGES from './locales/fr';
|
||||
import EMAIL_I18N_HE_MESSAGES from './locales/he';
|
||||
import EMAIL_I18N_HI_MESSAGES from './locales/hi';
|
||||
import EMAIL_I18N_HR_MESSAGES from './locales/hr';
|
||||
import EMAIL_I18N_HU_MESSAGES from './locales/hu';
|
||||
import EMAIL_I18N_ID_MESSAGES from './locales/id';
|
||||
import EMAIL_I18N_IT_MESSAGES from './locales/it';
|
||||
import EMAIL_I18N_JA_MESSAGES from './locales/ja';
|
||||
import EMAIL_I18N_KO_MESSAGES from './locales/ko';
|
||||
import EMAIL_I18N_LT_MESSAGES from './locales/lt';
|
||||
import EMAIL_I18N_NL_MESSAGES from './locales/nl';
|
||||
import EMAIL_I18N_NO_MESSAGES from './locales/no';
|
||||
import EMAIL_I18N_PL_MESSAGES from './locales/pl';
|
||||
import EMAIL_I18N_PT_BR_MESSAGES from './locales/pt-BR';
|
||||
import EMAIL_I18N_RO_MESSAGES from './locales/ro';
|
||||
import EMAIL_I18N_RU_MESSAGES from './locales/ru';
|
||||
import EMAIL_I18N_SV_SE_MESSAGES from './locales/sv-SE';
|
||||
import EMAIL_I18N_TH_MESSAGES from './locales/th';
|
||||
import EMAIL_I18N_TR_MESSAGES from './locales/tr';
|
||||
import EMAIL_I18N_UK_MESSAGES from './locales/uk';
|
||||
import EMAIL_I18N_VI_MESSAGES from './locales/vi';
|
||||
import EMAIL_I18N_ZH_CN_MESSAGES from './locales/zh-CN';
|
||||
import EMAIL_I18N_ZH_TW_MESSAGES from './locales/zh-TW';
|
||||
import EMAIL_I18N_AR_MESSAGES from '@pkgs/email/src/email_i18n/locales/ar';
|
||||
import EMAIL_I18N_BG_MESSAGES from '@pkgs/email/src/email_i18n/locales/bg';
|
||||
import EMAIL_I18N_CS_MESSAGES from '@pkgs/email/src/email_i18n/locales/cs';
|
||||
import EMAIL_I18N_DA_MESSAGES from '@pkgs/email/src/email_i18n/locales/da';
|
||||
import EMAIL_I18N_DE_MESSAGES from '@pkgs/email/src/email_i18n/locales/de';
|
||||
import EMAIL_I18N_EL_MESSAGES from '@pkgs/email/src/email_i18n/locales/el';
|
||||
import EMAIL_I18N_EN_GB_MESSAGES from '@pkgs/email/src/email_i18n/locales/en-GB';
|
||||
import EMAIL_I18N_ES_419_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-419';
|
||||
import EMAIL_I18N_ES_ES_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-ES';
|
||||
import EMAIL_I18N_FI_MESSAGES from '@pkgs/email/src/email_i18n/locales/fi';
|
||||
import EMAIL_I18N_FR_MESSAGES from '@pkgs/email/src/email_i18n/locales/fr';
|
||||
import EMAIL_I18N_HE_MESSAGES from '@pkgs/email/src/email_i18n/locales/he';
|
||||
import EMAIL_I18N_HI_MESSAGES from '@pkgs/email/src/email_i18n/locales/hi';
|
||||
import EMAIL_I18N_HR_MESSAGES from '@pkgs/email/src/email_i18n/locales/hr';
|
||||
import EMAIL_I18N_HU_MESSAGES from '@pkgs/email/src/email_i18n/locales/hu';
|
||||
import EMAIL_I18N_ID_MESSAGES from '@pkgs/email/src/email_i18n/locales/id';
|
||||
import EMAIL_I18N_IT_MESSAGES from '@pkgs/email/src/email_i18n/locales/it';
|
||||
import EMAIL_I18N_JA_MESSAGES from '@pkgs/email/src/email_i18n/locales/ja';
|
||||
import EMAIL_I18N_KO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ko';
|
||||
import EMAIL_I18N_LT_MESSAGES from '@pkgs/email/src/email_i18n/locales/lt';
|
||||
import EMAIL_I18N_NL_MESSAGES from '@pkgs/email/src/email_i18n/locales/nl';
|
||||
import EMAIL_I18N_NO_MESSAGES from '@pkgs/email/src/email_i18n/locales/no';
|
||||
import EMAIL_I18N_PL_MESSAGES from '@pkgs/email/src/email_i18n/locales/pl';
|
||||
import EMAIL_I18N_PT_BR_MESSAGES from '@pkgs/email/src/email_i18n/locales/pt-BR';
|
||||
import EMAIL_I18N_RO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ro';
|
||||
import EMAIL_I18N_RU_MESSAGES from '@pkgs/email/src/email_i18n/locales/ru';
|
||||
import EMAIL_I18N_SV_SE_MESSAGES from '@pkgs/email/src/email_i18n/locales/sv-SE';
|
||||
import EMAIL_I18N_TH_MESSAGES from '@pkgs/email/src/email_i18n/locales/th';
|
||||
import EMAIL_I18N_TR_MESSAGES from '@pkgs/email/src/email_i18n/locales/tr';
|
||||
import EMAIL_I18N_UK_MESSAGES from '@pkgs/email/src/email_i18n/locales/uk';
|
||||
import EMAIL_I18N_VI_MESSAGES from '@pkgs/email/src/email_i18n/locales/vi';
|
||||
import EMAIL_I18N_ZH_CN_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-CN';
|
||||
import EMAIL_I18N_ZH_TW_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-TW';
|
||||
|
||||
export const EMAIL_I18N_LOCALE_MESSAGES = {
|
||||
ar: EMAIL_I18N_AR_MESSAGES,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
donation_confirmation: {
|
||||
subject: 'Thank you for your {product_name} donation',
|
||||
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
|
||||
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
|
||||
},
|
||||
donation_magic_link: {
|
||||
subject: 'Manage your {product_name} donations',
|
||||
@@ -65,7 +65,7 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
password_change_verification: {
|
||||
subject: 'Confirm your {product_name} password change',
|
||||
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
|
||||
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
|
||||
},
|
||||
password_reset: {
|
||||
subject: 'Reset your {product_name} password',
|
||||
|
||||
@@ -111,6 +111,6 @@ export interface EmailTemplateVariables {
|
||||
};
|
||||
unban_notification: {
|
||||
username: string;
|
||||
reason: string;
|
||||
reason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
|
||||
"body": "مرحباً {username}،\n\nتم جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء تنفيذي جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nعملية الاستئناف:\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\nفي استئنافك:\n- اشرح بوضوح لماذا تعتقد أن قرار التنفيذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق حتى يتم التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nعملية الاستئناف:\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\nفي استئنافك:\n- اشرح بوضوح سبب اعتقادك أن قرار الإنفاذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق مؤقتًا إلى حين التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "تم تعليق حسابك في {product_name} مؤقتًا",
|
||||
"body": "مرحباً {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة} zero {0 ساعات} two {ساعتان}\n few {ساعات} many {ساعة} other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nخلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح لماذا تعتقد أن القرار كان غير صحيح. سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة}\n zero {# ساعة}\n two {ساعتان}\n few {# ساعات}\n many {# ساعة}\n other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nخلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح. سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "شكرًا لك على تبرعك لـ {product_name}",
|
||||
"body": "مرحباً،\n\nشكرًا لك على تبرعك لـ {product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلاً عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد في استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}{interval, select,\n month {}\n year {}\n other {}\n}"
|
||||
"subject": "شكرًا لك على تبرعك لـ{product_name}",
|
||||
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "إدارة تبرعات {product_name}",
|
||||
"body": "مرحباً،\n\nانقر على الرابط أدناه للوصول إلى بوابة المتبرعين الخاصة بك:\n\n{manageUrl}\n\nفي البوابة، يمكنك إدارة الاشتراكات، وتنزيل الفواتير، وعرض سجل تبرعاتك.\n\nينتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا الرابط، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\n– فريق {product_name}"
|
||||
"subject": "إدارة تبرعاتك لـ{product_name}",
|
||||
"body": "مرحبًا،\n\nانقر على الرابط أدناه للوصول إلى بوابة المتبرعين الخاصة بك:\n\n{manageUrl}\n\nفي البوابة، يمكنك إدارة الاشتراكات، وتنزيل الفواتير، وعرض سجل تبرعاتك.\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا الرابط، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\n– فريق {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "تحقق من بريدك الإلكتروني لتقديم تقرير DSA",
|
||||
"body": "مرحباً،\n\nاستخدم رمز التحقق أدناه لتقديم تقرير قانون الخدمات الرقمية الخاص بك على {product_name}:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق أمان {product_name}"
|
||||
"subject": "تحقق من بريدك الإلكتروني لتقديم بلاغ DSA",
|
||||
"body": "مرحبًا،\n\nاستخدم رمز التحقق أدناه لتقديم بلاغك بموجب قانون الخدمات الرقمية على {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "تحقق من بريدك الإلكتروني الجديد في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nأدخل هذا الرمز في التطبيق للتحقق من بريدك الإلكتروني الجديد في {product_name}:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nأدخل هذا الرمز في التطبيق للتحقق من بريدك الإلكتروني الجديد في {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "تأكيد تغيير بريدك الإلكتروني في {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}"
|
||||
"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}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "تم تغيير بريدك الإلكتروني في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nتم تغيير عنوان البريد الإلكتروني لحسابك في {product_name} إلى {newEmail}.\n\nإذا قمت بهذا التغيير، فلا داعي لاتخاذ أي إجراء. إذا لم تقم بذلك، يمكنك التراجع عن التغيير وتأمين حسابك باستخدام هذا الرابط:\n\n{revertUrl}\n\nسيؤدي هذا إلى استعادة بريدك الإلكتروني السابق، وتسجيل خروجك من جميع الأجهزة، وإزالة أرقام الهواتف المرتبطة، وتعطيل المصادقة متعددة العوامل، وسيطلب منك تعيين كلمة مرور جديدة.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nتم تغيير عنوان البريد الإلكتروني لحسابك في {product_name} إلى {newEmail}.\n\nإذا قمت بهذا التغيير، فلا داعي لاتخاذ أي إجراء. إذا لم تقم بذلك، يمكنك التراجع عن التغيير وتأمين حسابك باستخدام هذا الرابط:\n\n{revertUrl}\n\nسيؤدي هذا إلى استعادة بريدك الإلكتروني السابق، وتسجيل خروجك من جميع الأجهزة، وإزالة أرقام الهواتف المرتبطة، وتعطيل المصادقة متعددة العوامل، وسيُطلب منك تعيين كلمة مرور جديدة.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "تحقق من بريدك الإلكتروني في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "تمت إزالة المزايا من هديتك",
|
||||
"body": "مرحباً {username}،\n\nرمز الهدية الذي استرددته تم دفعه في الأصل من قبل شخص آخر. وقد تم عكس هذا الدفع منذ ذلك الحين (استرداد المبلغ).\n\nبسبب هذا، قمنا بإزالة المزايا التي أضيفت إلى حسابك عندما استرددت الهدية.\n\nإذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا وتضمين أي تفاصيل لديك حول رمز الهدية ومتى استرددته.\n\n– فريق {product_name}"
|
||||
"subject": "تمت إزالة مزايا الهدية التي استرددتها",
|
||||
"body": "مرحبًا {username}،\n\nدفع شخص آخر في الأصل ثمن رمز الهدية الذي استرددته، وقد تم التراجع عن تلك الدفعة لاحقًا (رد مبلغ مدفوع).\n\nولهذا السبب أزلنا المزايا التي أُضيفت إلى حسابك عند استرداد الهدية.\n\nإذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.\n\n– فريق {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "تصدير بياناتك من {product_name} جاهز للتنزيل",
|
||||
"body": "مرحباً {username}،\n\nتصدير بياناتك جاهز.\n\nرابط التنزيل:\n{downloadUrl}\n\nالرسائل المتضمنة: {totalMessages, number}\nحجم الملف: {fileSizeMB, number} ميجابايت\n\nينتهي هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والاتصال بفريق الدعم لدينا.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nتصدير بياناتك جاهز.\n\nرابط التنزيل:\n{downloadUrl}\n\nالرسائل المتضمنة: {totalMessages, number}\nحجم الملف: {fileSizeMB, number} ميجابايت\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والاتصال بفريق الدعم لدينا.\n\n– فريق {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
|
||||
"body": "مرحباً {username}،\n\nلم نرَ أي نشاط على حسابك في {product_name} منذ {lastActiveDate, date, full}.\n\nإذا لم تسجل الدخول بحلول {deletionDate, date, full} الساعة {deletionDate, time, short}، فسيتم حذف حسابك نهائيًا بسبب عدم النشاط.\n\nسجل الدخول هنا:\n{loginUrl}\n\nإذا كنت قد استخدمت {product_name} مؤخرًا، يرجى الاتصال بفريق الدعم لدينا على الفور.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلم نرَ أي نشاط على حسابك في {product_name} منذ {lastActiveDate, date, full}.\n\nإذا لم تسجل الدخول بحلول {deletionDate, date, full} الساعة {deletionDate, time, short}، فسيتم حذف حسابك نهائيًا بسبب عدم النشاط.\n\nسجل الدخول هنا:\n{loginUrl}\n\nإذا كنت قد استخدمت {product_name} مؤخرًا، يرجى الاتصال بفريق الدعم لدينا على الفور.\n\n– فريق {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "السماح بتسجيل الدخول من عنوان IP جديد",
|
||||
"body": "مرحباً {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى تفويض عنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لـ 30 دقيقة.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى السماح بعنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لمدة 30 دقيقة.\n\n– فريق {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "تأكيد الوصول إلى الرموز الاحتياطية في {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}"
|
||||
"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_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}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "إعادة تعيين كلمة المرور في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nلقد طلبت إعادة تعيين كلمة المرور الخاصة بك في {product_name}. استخدم الرابط أدناه لتعيين كلمة مرور جديدة:\n\n{resetUrl}\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لساعة واحدة.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلقد طلبت إعادة تعيين كلمة المرور الخاصة بك في {product_name}. استخدم الرابط أدناه لتعيين كلمة مرور جديدة:\n\n{resetUrl}\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة ساعة واحدة.\n\n– فريق {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "تمت الموافقة على تسجيلك في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nأخبار سارة: تمت الموافقة على تسجيلك في {product_name}.\n\nيمكنك الآن تسجيل الدخول إلى تطبيق {product_name} من هنا:\n{channelsUrl}\n\nمرحباً بك في مجتمع {product_name}.\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nأخبار سارة: تمت الموافقة على تسجيلك في {product_name}.\n\nيمكنك الآن تسجيل الدخول إلى تطبيق {product_name} من هنا:\n{channelsUrl}\n\nمرحبًا بك في مجتمع {product_name}.\n\n– فريق {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "تمت مراجعة تقريرك في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nتمت مراجعة تقريرك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع التقارير على محمل الجد ونقدر مساهمتك في المجتمع.\n\nإذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ {safety_email}.\n\n– فريق أمان {product_name}"
|
||||
"subject": "تمت مراجعة بلاغك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nتمت مراجعة بلاغك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع البلاغات على محمل الجد ونقدر مساهمتك في المجتمع.\n\nإذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ{safety_email}.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
|
||||
"body": "مرحباً {username}،\n\nتم جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء تنفيذي جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "تم جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تم جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
|
||||
"subject": "تمت جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تمت جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "تم رفع تعليق حسابك في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nيمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nيمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
@@ -9,23 +9,23 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Условия за ползване или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nТова е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nПрегледай:\n- Условия за ползване: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nПроцес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес.\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Предостави всякакви релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Общи условия или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nТова е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nПрегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nПроцес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес.\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Приложи всички релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Акаунтът ти във {product_name} е временно спрян",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Условия за ползване или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nПрез това време няма да имаш достъп до акаунта си.\n\nПрегледай:\n- Условия за ползване: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nАко смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно. Ние ще прегледаме обжалването ти и ще отговорим с нашето решение.\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Общи условия или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nПрез това време няма да имаш достъп до акаунта си.\n\nПрегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nАко смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно. Ще прегледаме обжалването ти и ще ти отговорим с решението си.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Благодарим ти за дарението за {product_name}",
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението за {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nStripe ще ти изпрати отделна разписка с PDF фактурата ти скоро. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да прегледаш историята на даренията си, да изтегляш фактури, {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
"subject": "Благодарим ти за дарението към {product_name}",
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Управлявай даренията си за {product_name}",
|
||||
"body": "Здравей,\n\nКликни върху линка по-долу, за да получиш достъп до портала за дарители:\n\n{manageUrl}\n\nВ портала можеш да управляваш планове, да изтегляш фактури и да преглеждаш историята на даренията си.\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този линк, можеш спокойно да игнорираш този имейл.\n\n– Екип на {product_name}"
|
||||
"body": "Здравей,\n\nКликни върху линка по-долу, за да получиш достъп до портала за дарители:\n\n{manageUrl}\n\nВ портала можеш да управляваш абонаменти, да изтегляш фактури и да преглеждаш историята на даренията си.\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този линк, можеш спокойно да игнорираш този имейл.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Потвърди имейла си за DSA доклад",
|
||||
"body": "Здравей,\n\nИзползвай кода за потвърждение по-долу, за да подадеш своя доклад по Закона за цифровите услуги във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип за безопасност на {product_name}"
|
||||
"subject": "Потвърди имейла си за сигнал по DSA",
|
||||
"body": "Здравей,\n\nИзползвай кода за потвърждение по-долу, за да подадеш сигнала си по Закона за цифровите услуги във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Потвърди новия си имейл във {product_name}",
|
||||
@@ -37,19 +37,19 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Имейлът ти във {product_name} беше променен",
|
||||
"body": "Здравей, {username},\n\nИмейл адресът на акаунта ти във {product_name} беше променен на {newEmail}.\n\nАко ти си направил тази промяна, не е необходимо да предприемаш никакви действия. Ако не си, можеш да отмениш промяната и да защитиш акаунта си, като използваш този линк:\n\n{revertUrl}\n\nТова ще възстанови предишния ти имейл, ще те излезе от всички устройства, ще премахне свързаните телефонни номера, ще деактивира MFA и ще изисква да зададеш нова парола.\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей, {username},\n\nИмейл адресът на акаунта ти във {product_name} беше променен на {newEmail}.\n\nАко ти си направил тази промяна, не е необходимо да предприемаш никакви действия. Ако не си, можеш да отмениш промяната и да защитиш акаунта си, като използваш този линк:\n\n{revertUrl}\n\nТова ще възстанови предишния ти имейл, ще прекрати всички активни сесии, ще премахне свързаните телефонни номера, ще деактивира MFA и ще изиска да зададеш нова парола.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Потвърди имейл адреса си във {product_name}",
|
||||
"body": "Здравей, {username},\n\nПотвърди имейл адреса за акаунта си във {product_name}, като кликнеш върху линка по-долу:\n\n{verifyUrl}\n\nАко не си създал акаунт във {product_name}, можеш спокойно да игнорираш този имейл.\n\nТози линк е валиден 24 часа.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Предимствата от активирания ти подарък са премахнати",
|
||||
"body": "Здравей, {username},\n\nКод за подарък, който си активирал, първоначално е бил платен от някой друг. Това плащане оттогава е отменено (chargeback).\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато активира подаръка.\n\nАко смяташ, че това е грешка, свържи се с нашия екип за поддръжка и включи всички подробности, които имаш относно кода за подарък и кога си го активирал.\n\n– Екип на {product_name}"
|
||||
"subject": "Предимствата от осребрения ти подарък са премахнати",
|
||||
"body": "Здравей, {username},\n\nКод за подарък, който си осребрил, първоначално е бил платен от някой друг. Оттогава плащането е върнато чрез оспорване на трансакция.\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато осребри подаръка.\n\nАко смяташ, че това е грешка, свържи се с нашия екип за поддръжка и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Експортът на данните ти от {product_name} е готов за изтегляне",
|
||||
"body": "Здравей, {username},\n\nЕкспортът на данните ти е готов.\n\nЛинк за изтегляне:\n{downloadUrl}\n\nВключени съобщения: {totalMessages, number}\nРазмер на файла: {fileSizeMB, number} MB\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, смени паролата си незабавно и се свържи с нашия екип за поддръжка.\n\n– Екип на {product_name}"
|
||||
"body": "Здравей, {username},\n\nЕкспортът на данните ти е готов.\n\nЛинк за изтегляне:\n{downloadUrl}\n\nВключени съобщения: {totalMessages, number}\nРазмер на файла: {fileSizeMB, number} MB\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този експорт, смени паролата си незабавно и се свържи с нашия екип за поддръжка.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
|
||||
@@ -76,8 +76,8 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Здравей, {username},\n\nДобра новина: регистрацията ти във {product_name} е одобрена.\n\nВече можеш да влезеш в приложението {product_name} тук:\n{channelsUrl}\n\nДобре дошъл в общността на {product_name}.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Докладът ти във {product_name} е прегледан",
|
||||
"body": "Здравей, {username},\n\nДокладът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш {product_name} да остане безопасно място за всички. Ние приемаме всички доклади сериозно и ценим твоя принос към общността.\n\nАко имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.\n\n– Екип за безопасност на {product_name}"
|
||||
"subject": "Сигналът ти във {product_name} е прегледан",
|
||||
"body": "Здравей, {username},\n\nСигналът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш на {product_name} да остане безопасно място за всички. Приемаме всички сигнали сериозно и ценим приноса ти към общността.\n\nАко имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Tvůj účet {product_name} byl dočasně deaktivován",
|
||||
"body": "Ahoj {username},\n\nDočasně jsme deaktivovali tvůj účet {product_name}, protože jsme zaznamenali podezřelou aktivitu.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nPro opětovný přístup k účtu si budeš muset resetovat heslo:\n\n{forgotUrl}\n\nPo resetování hesla se budeš moci znovu přihlásit.\n\nPokud se domníváš, že se jedná o chybu, kontaktuj prosím náš tým podpory.\n\n– Bezpečnostní tým {product_name}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tvůj účet {product_name} bude trvale smazán",
|
||||
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl naplánován k trvalému smazání z důvodu porušení našich Podmínek služby nebo Pokynů komunity.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Tvá data účtu budou trvale smazána v naplánovaný den.\n\nZkontroluj prosím:\n- Podmínky služby: {termsUrl}\n- Pokyny komunity: {guidelinesUrl}\n\nProces odvolání:\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné nebo neoprávněné, máš 60 dní na podání odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy.\n\nVe svém odvolání:\n- Jasně vysvětli, proč se domníváš, že rozhodnutí o vynucení bylo nesprávné nebo neoprávněné\n- Poskytni veškeré relevantní důkazy nebo kontext\n\nČlen bezpečnostního týmu {product_name} tvé odvolání přezkoumá a může pozastavit probíhající smazání, dokud nebude dosaženo konečného rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Váš účet {product_name} bude trvale smazán",
|
||||
"body": "Dobrý den, {username},\n\nKvůli porušení našich podmínek služby nebo komunitních pravidel bylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\nPřečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\nPostup odvolání:\nPokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, máte 60 dní na podání odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy.\n\nV odvolání:\n- Jasně vysvětlete, proč se domníváte, že rozhodnutí bylo nesprávné nebo neopodstatněné\n- Uveďte všechny relevantní důkazy a souvislosti\n\nČlen bezpečnostního týmu {product_name} vaše odvolání přezkoumá a může pozastavit plánované smazání, dokud nepadne konečné rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tvůj účet {product_name} byl dočasně pozastaven",
|
||||
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl dočasně pozastaven za porušení našich Podmínek služby nebo Pokynů komunity.\n\nDoba trvání: {durationHours, plural,\n one {1 hodina} few {hodiny}\n many {hodiny} other {# hodin}\n}\nPozastaveno do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nBěhem této doby nebudeš mít přístup ke svému účtu.\n\nZkontroluj prosím:\n- Podmínky služby: {termsUrl}\n- Pokyny komunity: {guidelinesUrl}\n\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné nebo neoprávněné, můžeš podat odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy a jasně vysvětli, proč se domníváš, že rozhodnutí bylo nesprávné. Tvé odvolání přezkoumáme a odpovíme ti s naším rozhodnutím.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Váš účet {product_name} byl dočasně pozastaven",
|
||||
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dočasně pozastaven kvůli porušení našich podmínek služby nebo komunitních pravidel.\n\nDoba trvání: {durationHours, plural,\n one {# hodina}\n few {# hodiny}\n many {# hodiny}\n other {# hodin}\n}\nPozastaven do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nBěhem této doby nebudete mít přístup ke svému účtu.\n\nPřečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\nPokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, můžete podat odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy a jasně vysvětlete, proč považujete rozhodnutí za nesprávné. Vaše odvolání přezkoumáme a sdělíme vám své rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Děkujeme za tvůj dar na {product_name}",
|
||||
"body": "Ahoj,\n\nDěkujeme za tvůj dar pro {product_name}! Tvůj {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n} úspěšně.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe ti brzy zašle samostatnou účtenku s PDF fakturou. Ta obsahuje všechny platební údaje a může být použita pro daňové účely.\n\nHistorii svých darů, stahování faktur {interval, select,\n month {a správu nebo zrušení předplatného}\n year {a správu nebo zrušení předplatného}\n other {a správu budoucích darů}\n} můžeš kdykoli zobrazit pomocí tohoto odkazu:\n\n{manageUrl}\n\nTvá podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
"subject": "Děkujeme za váš dar pro {product_name}",
|
||||
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Spravuj své dary pro {product_name}",
|
||||
"body": "Ahoj,\n\nKlikni na odkaz níže pro přístup k portálu pro dárce:\n\n{manageUrl}\n\nV portálu můžeš spravovat předplatná, stahovat faktury a prohlížet si historii svých darů.\n\nTento odkaz vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi tento odkaz nepožadoval, můžeš tento e-mail klidně ignorovat.\n\n– Tým {product_name}"
|
||||
"subject": "Spravujte své dary pro {product_name}",
|
||||
"body": "Dobrý den,\n\nKliknutím na odkaz níže otevřete portál pro dárce:\n\n{manageUrl}\n\nNa portálu můžete spravovat předplatná, stahovat faktury a prohlížet si historii svých darů.\n\nPlatnost tohoto odkazu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento odkaz nevyžádali, můžete tento e-mail bez obav ignorovat.\n\n– Tým {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Ověř svůj e-mail pro nahlášení podle DSA",
|
||||
"body": "Ahoj,\n\nPoužij ověřovací kód níže k odeslání svého nahlášení podle zákona o digitálních službách na {product_name}:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, můžeš tento e-mail ignorovat.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Ověřte svůj e-mail pro nahlášení podle DSA",
|
||||
"body": "Dobrý den,\n\nPomocí ověřovacího kódu níže odešlete své nahlášení podle nařízení o digitálních službách na {product_name}:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento kód nevyžádali, můžete tento e-mail ignorovat.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Ověř svůj nový e-mail pro {product_name}",
|
||||
"body": "Ahoj {username},\n\nZadej tento kód do aplikace pro ověření tvého nového e-mailu pro {product_name}:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, můžeš tento e-mail ignorovat.\n\n– Tým {product_name}"
|
||||
"subject": "Ověřte svou novou e-mailovou adresu pro {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nZadáním tohoto kódu v aplikaci ověříte svou novou e-mailovou adresu pro {product_name}:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento kód nevyžádali, můžete tento e-mail ignorovat.\n\n– Tým {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Potvrď změnu e-mailu pro {product_name}",
|
||||
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu e-mailové adresy tvého účtu {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, zabezpeč si svůj účet ihned.\n\n– Tým {product_name}"
|
||||
"subject": "Potvrďte změnu e-mailové adresy pro {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o změnu e-mailové adresy vašeho úč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 tuto změnu nežádali, ihned svůj účet zabezpečte.\n\n– Tým {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Tvůj e-mail pro {product_name} byl změněn",
|
||||
"body": "Ahoj {username},\n\nE-mailová adresa tvého účtu {product_name} byla změněna na {newEmail}.\n\nPokud jsi tuto změnu provedl ty, není potřeba žádná akce. Pokud ne, můžeš změnu vrátit zpět a zabezpečit svůj účet pomocí tohoto odkazu:\n\n{revertUrl}\n\nTím se obnoví tvůj předchozí e-mail, odhlásíš se všude, odstraní se propojená telefonní čísla, deaktivuje se MFA a budeš muset nastavit nové heslo.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Vaše e-mailová adresa pro {product_name} byla změněna",
|
||||
"body": "Dobrý den, {username},\n\nE-mailová adresa vašeho účtu {product_name} byla změněna na {newEmail}.\n\nPokud jste tuto změnu provedli vy, nemusíte nic dělat. Pokud ne, můžete ji vrátit zpět a zabezpečit svůj účet pomocí tohoto odkazu:\n\n{revertUrl}\n\nTím se obnoví vaše předchozí e-mailová adresa, budete odhlášeni ze všech zařízení, odstraní se propojená telefonní čísla, vypne se vícefaktorové ověřování a budete muset nastavit nové heslo.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Ověř svou e-mailovou adresu pro {product_name}",
|
||||
"body": "Ahoj {username},\n\nOvěř e-mailovou adresu svého účtu {product_name} kliknutím na odkaz níže:\n\n{verifyUrl}\n\nPokud jsi si účet {product_name} nevytvořil, můžeš tento e-mail klidně ignorovat.\n\nTento odkaz je platný 24 hodin.\n\n– Tým {product_name}"
|
||||
"subject": "Ověřte svou e-mailovou adresu pro {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nKliknutím na odkaz níže ověřte e-mailovou adresu svého účtu {product_name}:\n\n{verifyUrl}\n\nPokud jste si účet {product_name} nevytvořili, můžete tento e-mail bez obav ignorovat.\n\nTento odkaz je platný 24 hodin.\n\n– Tým {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Výhody z uplatněného dárku byly odstraněny",
|
||||
"body": "Ahoj {username},\n\nDárkový kód, který jsi uplatnil, byl původně zaplacen někým jiným. Tato platba byla mezitím zrušena (chargeback).\n\nZ tohoto důvodu jsme odstranili výhody, které byly přidány k tvému účtu, když jsi dárek uplatnil.\n\nPokud si myslíš, že se jedná o chybu, kontaktuj prosím náš tým podpory a uveď veškeré podrobnosti, které máš o dárkovém kódu a o tom, kdy jsi ho uplatnil.\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nDárkový kód, který jste uplatnili, původně zaplatil někdo jiný. Tato platba byla následně vrácena na základě reklamace (chargeback).\n\nProto jsme odebrali výhody, které váš účet získal při uplatnění dárku.\n\nPokud se domníváte, že jde o chybu, kontaktujte náš tým podpory a uveďte všechny dostupné podrobnosti o dárkovém kódu a o tom, kdy jste ho uplatnili.\n\n– Tým {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Export tvých dat z {product_name} je připraven ke stažení",
|
||||
"body": "Ahoj {username},\n\nTvůj export dat je připraven.\n\nOdkaz ke stažení:\n{downloadUrl}\n\nZahrnuté zprávy: {totalMessages, number}\nVelikost souboru: {fileSizeMB, number} MB\n\nTento odkaz vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o tento export nežádal, okamžitě si změň heslo a kontaktuj náš tým podpory.\n\n– Tým {product_name}"
|
||||
"subject": "Export vašich dat z {product_name} je připraven ke stažení",
|
||||
"body": "Dobrý den, {username},\n\nExport vašich dat je připraven.\n\nOdkaz ke stažení:\n{downloadUrl}\n\nPočet zahrnutých zpráv: {totalMessages, number}\nVelikost souboru: {fileSizeMB, number} MB\n\nPlatnost tohoto odkazu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento export nevyžádali, okamžitě si změňte heslo a kontaktujte náš tým podpory.\n\n– Tým {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Tvůj účet {product_name} bude smazán kvůli neaktivitě",
|
||||
"body": "Ahoj {username},\n\nNezaznamenali jsme žádnou aktivitu na tvém účtu {product_name} od {lastActiveDate, date, full}.\n\nPokud se nepřihlásíš do {deletionDate, date, full} v {deletionDate, time, short}, tvůj účet bude trvale smazán kvůli neaktivitě.\n\nPřihlas se zde:\n{loginUrl}\n\nPokud jsi {product_name} nedávno používal, kontaktuj náš tým podpory ihned.\n\n– Tým {product_name}"
|
||||
"subject": "Váš účet {product_name} bude smazán kvůli neaktivitě",
|
||||
"body": "Dobrý den, {username},\n\nOd {lastActiveDate, date, full} jsme na vašem účtu {product_name} nezaznamenali žádnou aktivitu.\n\nPokud se nepřihlásíte do {deletionDate, date, full} v {deletionDate, time, short}, váš účet bude kvůli neaktivitě trvale smazán.\n\nPřihlaste se zde:\n{loginUrl}\n\nPokud jste {product_name} v poslední době používali, ihned kontaktujte náš tým podpory.\n\n– Tým {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Povolit přihlášení z nové IP adresy",
|
||||
"body": "Ahoj {username},\n\nZaznamenali jsme pokus o přihlášení k tvému účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jsi to byl ty, autorizuj tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jsi se nepřihlašoval, okamžitě si změň heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nZaznamenali jsme pokus o přihlášení k vašemu účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jste to byli vy, povolte tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jste se nepokoušeli přihlásit, ihned si změňte heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Potvrď přístup k záložním kódům pro {product_name}",
|
||||
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na zobrazení záložních kódů tvého účtu {product_name}.\n\nPro potvrzení tohoto požadavku zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo.\n\n– Tým {product_name}"
|
||||
"subject": "Potvrďte přístup k záložním kódům pro {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o zobrazení záložních kódů vašeho účtu {product_name}.\n\nŽádost 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.\n\n– Tým {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potvrď změnu hesla k {product_name}",
|
||||
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu hesla k tvému účtu pro {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší v {expiresAt}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo a povol dvoufaktorové ověřování.\n\n– Tým {product_name}"
|
||||
"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}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Obnov své heslo k {product_name}",
|
||||
"body": "Ahoj {username},\n\nPožádal jsi o reset hesla pro {product_name}. Použij odkaz níže pro nastavení nového hesla:\n\n{resetUrl}\n\nPokud jsi o to nežádal, můžeš tento e-mail klidně ignorovat.\n\nTento odkaz je platný 1 hodinu.\n\n– Tým {product_name}"
|
||||
"subject": "Obnovte své heslo k účtu {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nPožádali jste o obnovení hesla k účtu {product_name}. Pomocí odkazu níže si nastavte nové heslo:\n\n{resetUrl}\n\nPokud jste o to nežádali, můžete tento e-mail bez obav ignorovat.\n\nTento odkaz je platný 1 hodinu.\n\n– Tým {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "Tvá registrace k {product_name} byla schválena",
|
||||
"body": "Ahoj {username},\n\nDobré zprávy: tvá registrace do {product_name} byla schválena.\n\nNyní se můžeš přihlásit do aplikace {product_name} zde:\n{channelsUrl}\n\nVítej v komunitě {product_name}.\n\n– Tým {product_name}"
|
||||
"subject": "Vaše registrace do {product_name} byla schválena",
|
||||
"body": "Dobrý den, {username},\n\nDobrá zpráva: vaše registrace do {product_name} byla schválena.\n\nNyní se můžete přihlásit do aplikace {product_name} zde:\n{channelsUrl}\n\nVítejte v komunitě {product_name}.\n\n– Tým {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Tvé nahlášení pro {product_name} bylo zkontrolováno",
|
||||
"body": "Ahoj {username},\n\nTvé nahlášení (ID: {reportId}) bylo zkontrolováno naším bezpečnostním týmem.{hasComment, select, yes {\n\nOdpověď od bezpečnostního týmu:\n{publicComment}} other {}}\n\nDěkujeme, že pomáháš udržovat {product_name} bezpečný pro všechny. Všechna nahlášení bereme vážně a oceňujeme tvůj příspěvek komunitě.\n\nPokud máš jakékoli dotazy nebo obavy ohledně tohoto výsledku, kontaktuj {safety_email}.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Vaše nahlášení na {product_name} bylo přezkoumáno",
|
||||
"body": "Dobrý den, {username},\n\nNáš bezpečnostní tým přezkoumal vaše nahlášení (ID: {reportId}).{hasComment, select, yes {\n\nOdpověď bezpečnostního týmu:\n{publicComment}} other {}}\n\nDěkujeme, že pomáháte udržovat {product_name} bezpečný pro všechny. Všechna nahlášení bereme vážně a vážíme si vašeho přínosu komunitě.\n\nPokud máte k tomuto výsledku jakékoli dotazy nebo výhrady, kontaktujte {safety_email}.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tvůj účet {product_name} bude trvale smazán",
|
||||
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl naplánován k trvalému smazání.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Tvá data účtu budou trvale smazána v naplánovaný den.\n\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné, můžeš podat odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Váš účet {product_name} bude trvale smazán",
|
||||
"body": "Dobrý den, {username},\n\nBylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\nPokud se domníváte, že toto rozhodnutí bylo nesprávné, můžete podat odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Smazání tvého účtu {product_name} je naplánováno",
|
||||
"body": "Ahoj {username},\n\nPožádal jsi o smazání svého účtu {product_name}. Tvůj účet je naplánován k trvalému smazání na:\n\n{deletionDate, date, full} v {deletionDate, time, short}\n\nPokud jsi to nepožadoval, přihlas se ke svému účtu a zruš smazání. Doporučujeme také změnit si heslo pro zabezpečení účtu.\n\n– Tým {product_name}"
|
||||
"subject": "Smazání vašeho účtu {product_name} je naplánováno",
|
||||
"body": "Dobrý den, {username},\n\nPožádali jste o smazání svého účtu {product_name}. Trvalé smazání je naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}\n\nPokud jste o smazání nežádali, přihlaste se ke svému účtu a smazání zrušte. Doporučujeme také změnit heslo, abyste svůj účet zabezpečili.\n\n– Tým {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Pozastavení tvého účtu {product_name} bylo zrušeno",
|
||||
"body": "Ahoj {username},\n\nDobré zprávy: pozastavení tvého účtu {product_name} bylo zrušeno.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nNyní se můžeš znovu přihlásit a pokračovat v používání {product_name} jako obvykle.\n\n– Bezpečnostní tým {product_name}"
|
||||
"subject": "Pozastavení vašeho účtu {product_name} bylo zrušeno",
|
||||
"body": "Dobrý den, {username},\n\nDobrá zpráva: pozastavení vašeho účtu {product_name} bylo zrušeno.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nNyní se můžete znovu přihlásit a používat {product_name} jako obvykle.\n\n– Bezpečnostní tým {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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– {product_name} Safety Team"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Din {product_name}-konto slettes permanent",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto er planlagt til permanent sletning på grund af overtrædelser af vores servicevilkår eller retningslinjer for community'et.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig håndhævelseshandling. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for community'et: {guidelinesUrl}\n\nAnkeproces:\nHvis du mener, at denne håndhævelsesbeslutning var forkert eller uberettiget, har du 60 dage til at indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\nI din anke:\n- Forklar tydeligt, hvorfor du mener, at håndhævelsesbeslutningen var forkert eller uberettiget\n- Fremlæg relevant bevis eller kontekst\n\nEt medlem af {product_name} Safety Team vil gennemgå din anke og kan sætte den afventende sletning på pause, indtil en endelig beslutning er truffet.\n\n– {product_name} Safety Team"
|
||||
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\nKlageproces:\nHvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, har du 60 dage til at indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\nI din klage:\n- Forklar tydeligt, hvorfor du mener, at beslutning om sanktioneren var forkert eller uberettiget\n- Fremlæg relevant dokumentation eller kontekst\n\nEt medlem af sikkerhedsteamet hos {product_name} vil gennemgå din klage og kan sætte den afventende sletning på pause, indtil en endelig beslutning er truffet.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Din {product_name}-konto er midlertidigt suspenderet",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto er midlertidigt suspenderet for at overtræde vores servicevilkår eller retningslinjer for community'et.\n\nVarighed: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspenderet indtil: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nI denne periode vil du ikke kunne få adgang til din konto.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for community'et: {guidelinesUrl}\n\nHvis du mener, at denne håndhævelsesbeslutning var forkert eller uberettiget, kan du indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse og forklar tydeligt, hvorfor du mener, at beslutningen var forkert. Vi vil gennemgå din anke og svare med vores beslutning.\n\n– {product_name} Safety Team"
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto er midlertidigt suspenderet på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nVarighed: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspenderet indtil: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nI denne periode vil du ikke kunne få adgang til din konto.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\nHvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, kan du indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse og forklar tydeligt, hvorfor du mener, at beslutningen var forkert. Vi gennemgår din klage og giver dig besked om vores afgørelse.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tak for din {product_name}-donation",
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {tilbagevendende donation}\n year {tilbagevendende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {opsat}\n year {opsat}\n other {behandlet}\n} med succes.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura-PDF inden længe. Denne inkluderer alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer, {interval, select,\n month {og administrere eller annullere dit abonnement}\n year {og administrere eller annullere dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– {product_name} Team"
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administrer dine {product_name}-donationer",
|
||||
"body": "Hej,\n\nKlik på linket nedenfor for at få adgang til din donorportal:\n\n{manageUrl}\n\nI portalen kan du administrere abonnementer, downloade fakturaer og se din donationshistorik.\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette link, kan du trygt ignorere denne e-mail.\n\n– {product_name} Team"
|
||||
"body": "Hej,\n\nKlik på linket nedenfor for at få adgang til din donorportal:\n\n{manageUrl}\n\nI portalen kan du administrere abonnementer, downloade fakturaer og se din donationshistorik.\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette link, kan du trygt ignorere denne e-mail.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Bekræft din e-mail til en DSA-rapport",
|
||||
"body": "Hej,\n\nBrug bekræftelseskoden nedenfor til at indsende din Digital Services Act-rapport for {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– {product_name} Safety Team"
|
||||
"subject": "Bekræft din e-mailadresse til en DSA-anmeldelse",
|
||||
"body": "Hej,\n\nBrug bekræftelseskoden nedenfor til at indsende din anmeldelse i henhold til Digital Services Act på {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Bekræft din nye {product_name}-e-mail",
|
||||
"body": "Hej {username},\n\nIndtast denne kode i appen for at bekræfte din nye {product_name}-e-mail:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– {product_name} Team"
|
||||
"subject": "Bekræft din nye e-mailadresse til {product_name}",
|
||||
"body": "Hej {username},\n\nIndtast denne kode i appen for at bekræfte din nye e-mailadresse til {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Bekræft din {product_name}-e-mail-ændring",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre e-mailadressen 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, skal du sikre din konto med det samme.\n\n– {product_name} Team"
|
||||
"subject": "Bekræft ændringen af din e-mailadresse til {product_name}",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre e-mailadressen 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, skal du sikre din konto med det samme.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Din {product_name}-e-mail blev ændret",
|
||||
"body": "Hej {username},\n\nE-mailadressen på din {product_name}-konto blev ændret til {newEmail}.\n\nHvis du har foretaget denne ændring, er der ingen handling nødvendig. Hvis du ikke har, kan du fortryde ændringen og sikre din konto ved at bruge dette link:\n\n{revertUrl}\n\nDette vil gendanne din tidligere e-mail, logge dig ud overalt, fjerne tilknyttede telefonnumre, deaktivere MFA og kræve, at du indstiller en ny adgangskode.\n\n– {product_name} Safety Team"
|
||||
"subject": "Din e-mailadresse til {product_name} er ændret",
|
||||
"body": "Hej {username},\n\nE-mailadressen på din {product_name}-konto blev ændret til {newEmail}.\n\nHvis du selv har foretaget ændringen, behøver du ikke gøre noget. Ellers kan du fortryde ændringen og sikre din konto ved at bruge dette link:\n\n{revertUrl}\n\nDette vil gendanne din tidligere e-mailadresse, logge dig ud overalt, fjerne tilknyttede telefonnumre, slå multifaktorgodkendelse fra og kræve, at du vælger en ny adgangskode.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Bekræft din {product_name}-e-mailadresse",
|
||||
"body": "Hej {username},\n\nBekræft e-mailadressen for din {product_name}-konto ved at klikke på linket nedenfor:\n\n{verifyUrl}\n\nHvis du ikke har oprettet en {product_name}-konto, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 24 timer.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nBekræft e-mailadressen for din {product_name}-konto ved at klikke på linket nedenfor:\n\n{verifyUrl}\n\nHvis du ikke har oprettet en {product_name}-konto, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 24 timer.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Fordele fra din indløste gave er fjernet",
|
||||
"body": "Hej {username},\n\nEn gavekode, du indløste, blev oprindeligt betalt af en anden. Den betaling er siden annulleret (en tilbageførsel).\n\nDerfor har vi fjernet de fordele, der blev tilføjet din konto, da du indløste gaven.\n\nHvis du mener, at dette er en fejl, kan du kontakte vores supportteam og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nEn gavekode, du indløste, blev oprindeligt betalt af en anden. Den betaling er siden annulleret (en tilbageførsel).\n\nDerfor har vi fjernet de fordele, der blev tilføjet din konto, da du indløste gaven.\n\nHvis du mener, at dette er en fejl, kan du kontakte vores supportteam og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Din {product_name}-dataeksport er klar til at downloade",
|
||||
"body": "Hej {username},\n\nDin dataeksport er klar.\n\nDownload-link:\n{downloadUrl}\n\nAntal beskeder: {totalMessages, number}\nFilstørrelse: {fileSizeMB, number} MB\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om denne eksport, skal du straks ændre din adgangskode og kontakte vores supportteam.\n\n– {product_name} Team"
|
||||
"subject": "Din dataeksport fra {product_name} er klar til download",
|
||||
"body": "Hej {username},\n\nDin dataeksport er klar.\n\nDownload-link:\n{downloadUrl}\n\nAntal beskeder: {totalMessages, number}\nFilstørrelse: {fileSizeMB, number} MB\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om denne eksport, skal du straks ændre din adgangskode og kontakte vores supportteam.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Din {product_name}-konto slettes på grund af inaktivitet",
|
||||
"body": "Hej {username},\n\nVi har ikke set nogen aktivitet på din {product_name}-konto siden {lastActiveDate, date, full}.\n\nHvis du ikke logger ind inden {deletionDate, date, full} kl. {deletionDate, time, short}, slettes din konto permanent på grund af inaktivitet.\n\nLog ind her:\n{loginUrl}\n\nHvis du har brugt {product_name} for nylig, skal du kontakte vores supportteam med det samme.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nVi har ikke set nogen aktivitet på din {product_name}-konto siden {lastActiveDate, date, full}.\n\nHvis du ikke logger ind inden {deletionDate, date, full} kl. {deletionDate, time, short}, slettes din konto permanent på grund af inaktivitet.\n\nLog ind her:\n{loginUrl}\n\nHvis du har brugt {product_name} for nylig, skal du kontakte vores supportteam med det samme.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Godkend login fra en ny IP-adresse",
|
||||
"body": "Hej {username},\n\nVi har registreret et login-forsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nVi har registreret et loginforsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis det var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bekræft adgang til dine {product_name}-backupkoder",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning 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.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning 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.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekræft din {product_name}-adgangskodeændring",
|
||||
"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 totrinsgodkendelse.\n\n– {product_name} Team"
|
||||
"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}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Nulstil din {product_name}-adgangskode",
|
||||
"body": "Hej {username},\n\nDu har anmodet om at nulstille din {product_name}-adgangskode. Brug linket nedenfor til at indstille en ny adgangskode:\n\n{resetUrl}\n\nHvis du ikke har anmodet om dette, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 1 time.\n\n– {product_name} Team"
|
||||
"body": "Hej {username},\n\nDu har anmodet om at nulstille din {product_name}-adgangskode. Brug linket nedenfor til at vælge en ny adgangskode:\n\n{resetUrl}\n\nHvis du ikke har anmodet om dette, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 1 time.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "Din {product_name}-registrering er godkendt",
|
||||
"body": "Hej {username},\n\nGode nyheder: Din {product_name}-registrering er godkendt.\n\nDu kan nu logge ind på {product_name}-appen her:\n{channelsUrl}\n\nVelkommen til {product_name}-community'et.\n\n– {product_name} Team"
|
||||
"subject": "Din tilmelding til {product_name} er godkendt",
|
||||
"body": "Hej {username},\n\nGode nyheder: Din tilmelding til {product_name} er godkendt.\n\nDu kan nu logge ind på {product_name}-appen her:\n{channelsUrl}\n\nVelkommen til fællesskabet på {product_name}.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Din {product_name}-rapport er gennemgået",
|
||||
"body": "Hej {username},\n\nDin rapport (ID: {reportId}) er gennemgået af vores Safety Team.{hasComment, select, yes {\n\nSvar fra Safety Team:\n{publicComment}} other {}}\n\nTak for din hjælp til at holde {product_name} sikkert for alle. Vi tager alle rapporter alvorligt og værdsætter dit bidrag til community'et.\n\nHvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte {safety_email}.\n\n– {product_name} Safety Team"
|
||||
"subject": "Din anmeldelse på {product_name} er blevet behandlet",
|
||||
"body": "Hej {username},\n\nDin anmeldelse (ID: {reportId}) er blevet behandlet af vores sikkerhedsteam.{hasComment, select, yes {\n\nSvar fra sikkerhedsteamet:\n{publicComment}} other {}}\n\nTak for din hjælp til at holde {product_name} sikkert for alle. Vi tager alle anmeldelser alvorligt og værdsætter dit bidrag til fællesskabet.\n\nHvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte {safety_email}.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Din {product_name}-konto slettes permanent",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto er planlagt til permanent sletning.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig håndhævelseshandling. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nHvis du mener, at denne håndhævelsesbeslutning var forkert, kan du indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\n– {product_name} Safety Team"
|
||||
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nHvis du mener, at denne beslutning om sanktioner var forkert, kan du indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Din {product_name}-kontosletning er planlagt",
|
||||
"body": "Hej {username},\n\nDu har anmodet om at slette din {product_name}-konto. Din konto slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke har anmodet om dette, skal du logge ind på din konto for at annullere sletningen. Vi anbefaler også, at du ændrer din adgangskode for at sikre din konto.\n\n– {product_name} Team"
|
||||
"subject": "Sletningen af din {product_name}-konto er planlagt",
|
||||
"body": "Hej {username},\n\nDu har anmodet om at slette din {product_name}-konto. Din konto slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke har anmodet om dette, skal du logge ind på din konto for at annullere sletningen. Vi anbefaler også, at du ændrer din adgangskode for at sikre din konto.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Din {product_name}-kontosuspension er ophævet",
|
||||
"body": "Hej {username},\n\nGode nyheder: Din {product_name}-kontosuspension er ophævet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDu kan nu logge ind igen og fortsætte med at bruge {product_name} som normalt.\n\n– {product_name} Safety Team"
|
||||
"subject": "Suspensionen af din {product_name}-konto er ophævet",
|
||||
"body": "Hej {username},\n\nGode nyheder: Suspensionen af din {product_name}-konto er ophævet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDu kan nu logge ind igen og fortsætte med at bruge {product_name} som normalt.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
@@ -9,7 +9,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die ausstehende Löschung möglicherweise pausieren, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die geplante Löschung möglicherweise aussetzen, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
|
||||
@@ -17,67 +17,67 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Vielen Dank für deine {product_name}-Spende",
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nDu kannst deine Spendenhistorie einsehen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement jederzeit über diesen Link verwalten oder kündigen}\n year {und dein Abonnement jederzeit über diesen Link verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name} Team"
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Verwalte deine {product_name}-Spenden",
|
||||
"body": "Hallo,\n\nklicke auf den Link unten, um auf dein Spenderportal zuzugreifen:\n\n{manageUrl}\n\nIm Portal kannst du Abonnements verwalten, Rechnungen herunterladen und deine Spendenhistorie einsehen.\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name} Team"
|
||||
"body": "Hallo,\n\nklicke auf den Link unten, um auf dein Spendenportal zuzugreifen:\n\n{manageUrl}\n\nIm Portal kannst du Abonnements verwalten, Rechnungen herunterladen und deine Spendenübersicht einsehen.\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Team"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Bestätige deine E-Mail für einen DSA-Bericht",
|
||||
"body": "Hallo,\n\nverwende den untenstehenden Bestätigungscode, um deinen DSA-Bericht auf {product_name} einzureichen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Bestätige deine E-Mail-Adresse für eine DSA-Meldung",
|
||||
"body": "Hallo,\n\nverwende den untenstehenden Bestätigungscode, um deine Meldung nach dem Digital Services Act (DSA) auf {product_name} einzureichen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Bestätige deine neue {product_name}-E-Mail",
|
||||
"body": "Hallo {username},\n\ngib diesen Code in der App ein, um deine neue {product_name}-E-Mail zu bestätigen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name} Team"
|
||||
"subject": "Bestätige deine neue {product_name}-E-Mail-Adresse",
|
||||
"body": "Hallo {username},\n\ngib diesen Code in der App ein, um deine neue {product_name}-E-Mail-Adresse zu bestätigen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Team"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Bestätige deine {product_name}-E-Mail-Änderung",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse 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, sichere deinen Account bitte sofort ab.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse 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, sichere deinen Account bitte sofort ab.\n\n– {product_name}-Team"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Deine {product_name}-E-Mail wurde geändert",
|
||||
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Accounts wurde zu {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und deinen Account über diesen Link absichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Deine {product_name}-E-Mail-Adresse wurde geändert",
|
||||
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Accounts wurde in {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und deinen Account über diesen Link absichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail-Adresse wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Bestätige deine {product_name}-E-Mail-Adresse",
|
||||
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name}-Team"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Vorteile deines eingelösten Geschenks wurden entfernt",
|
||||
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name}-Team"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Dein {product_name}-Datenexport steht zum Herunterladen bereit",
|
||||
"body": "Hallo {username},\n\ndein Datenexport ist bereit.\n\nDownload-Link:\n{downloadUrl}\n\nEnthaltene Nachrichten: {totalMessages, number}\nDateigröße: {fileSizeMB, number} MB\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Export nicht angefordert hast, ändere bitte sofort dein Passwort und kontaktiere unser Support-Team.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\ndein Datenexport ist bereit.\n\nDownload-Link:\n{downloadUrl}\n\nEnthaltene Nachrichten: {totalMessages, number}\nDateigröße: {fileSizeMB, number} MB\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Export nicht angefordert hast, ändere bitte sofort dein Passwort und kontaktiere unser Support-Team.\n\n– {product_name}-Team"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Dein {product_name}-Account wird wegen Inaktivität gelöscht",
|
||||
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Account festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Account aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Account festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Account aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name}-Team"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Anmeldung von einer neuen IP-Adresse bestätigen",
|
||||
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du nicht versucht hast, dich anzumelden, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name}-Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bestätige den Zugriff auf deine {product_name}-Backup-Codes",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage 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 dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage 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 dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name}-Team"
|
||||
},
|
||||
"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 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"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Setze dein {product_name}-Passwort zurück",
|
||||
"body": "Hallo {username},\n\ndu hast eine {product_name}-Passwortzurücksetzung angefordert. Verwende den untenstehenden Link, um ein neues Passwort festzulegen:\n\n{resetUrl}\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 1 Stunde gültig.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\ndu hast angefordert, dein {product_name}-Passwort zurückzusetzen. Verwende den untenstehenden Link, um ein neues Passwort festzulegen:\n\n{resetUrl}\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 1 Stunde gültig.\n\n– {product_name}-Team"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "Deine {product_name}-Registrierung wurde genehmigt",
|
||||
"body": "Hallo {username},\n\ngute Nachrichten: Deine {product_name}-Registrierung wurde genehmigt.\n\nDu kannst dich jetzt hier in die {product_name}-App einloggen:\n{channelsUrl}\n\nWillkommen in der {product_name}-Community.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\ngute Nachrichten: Deine {product_name}-Registrierung wurde genehmigt.\n\nDu kannst dich jetzt hier in die {product_name}-App einloggen:\n{channelsUrl}\n\nWillkommen in der {product_name}-Community.\n\n– {product_name}-Team"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Dein {product_name}-Bericht wurde geprüft",
|
||||
"body": "Hallo {username},\n\ndein Bericht (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Berichte ernst und schätzen deinen Beitrag zur Community.\n\nWenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Deine {product_name}-Meldung wurde geprüft",
|
||||
"body": "Hallo {username},\n\ndeine Meldung (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Meldungen ernst und schätzen deinen Beitrag zur Community.\n\nWenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
@@ -85,7 +85,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
|
||||
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account ist zur dauerhaften Löschung vorgesehen am:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account wird an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name}-Team"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων Παροχής Υπηρεσιών ή των Οδηγιών Κοινότητας.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για ένα σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΈλεγξε:\n- Όροι Παροχής Υπηρεσιών: {termsUrl}\n- Οδηγίες Κοινότητας: {guidelinesUrl}\n\nΔιαδικασία προσφυγής:\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, έχεις 60 ημέρες για να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\nΣτην προσφυγή σου:\n- Εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη\n- Παράθεσε τυχόν σχετικά αποδεικτικά στοιχεία ή πλαίσιο\n\nΈνα μέλος της Ομάδας Ασφαλείας του {product_name} θα εξετάσει την προσφυγή σου και ενδέχεται να αναστείλει την εκκρεμή διαγραφή μέχρι να ληφθεί μια τελική απόφαση.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΈλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\nΔιαδικασία προσφυγής:\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, έχεις 60 ημέρες για να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\nΣτην προσφυγή σου:\n- Εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη\n- Παράθεσε τυχόν σχετικά αποδεικτικά στοιχεία ή πλαίσιο\n\nΈνα μέλος της ομάδας ασφαλείας του {product_name} θα εξετάσει την προσφυγή σου και ενδέχεται να αναστείλει την εκκρεμή διαγραφή μέχρι να ληφθεί τελική απόφαση.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά για παραβίαση των Όρων Παροχής Υπηρεσιών ή των Οδηγιών Κοινότητας.\n\nΔιάρκεια: {durationHours, plural,\n =1 {μία ώρα}\n other {# ώρες}\n}\nΑναστολή μέχρι: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΚατά τη διάρκεια αυτής της περιόδου, δεν θα μπορείς να έχεις πρόσβαση στον λογαριασμό σου.\n\nΈλεγξε:\n- Όροι Παροχής Υπηρεσιών: {termsUrl}\n- Οδηγίες Κοινότητας: {guidelinesUrl}\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη. Θα εξετάσουμε την προσφυγή σου και θα απαντήσουμε με την απόφασή μας.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά για παραβίαση των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΔιάρκεια: {durationHours, plural,\n =1 {μία ώρα}\n other {# ώρες}\n}\nΑναστολή μέχρι: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΚατά τη διάρκεια αυτής της περιόδου, δεν θα έχεις πρόσβαση στον λογαριασμό σου.\n\nΈλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη. Θα εξετάσουμε την προσφυγή σου και θα σου απαντήσουμε με την απόφασή μας.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {επεξεργαστεί}\n} επιτυχώς.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα μια ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Αυτό περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Διαχείριση των δωρεών σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΚάνε κλικ στον παρακάτω σύνδεσμο για να αποκτήσεις πρόσβαση στην πύλη δωρητών σου:\n\n{manageUrl}\n\nΣτην πύλη, μπορείς να διαχειριστείς τις συνδρομές, να κατεβάσεις τιμολόγια και να δεις το ιστορικό των δωρεών σου.\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτόν τον σύνδεσμο, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\n– Ομάδα {product_name}"
|
||||
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΚάνε κλικ στον παρακάτω σύνδεσμο για να αποκτήσεις πρόσβαση στην πύλη δωρητών σου:\n\n{manageUrl}\n\nΣτην πύλη μπορείς να διαχειριστείς τις συνδρομές, να κατεβάσεις τιμολόγια και να δεις το ιστορικό των δωρεών σου.\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτόν τον σύνδεσμο, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Επαλήθευσε το email σου για μια αναφορά DSA",
|
||||
"body": "Γεια σου,\n\nΧρησιμοποίησε τον παρακάτω κωδικό επαλήθευσης για να υποβάλεις την αναφορά σου για τον Νόμο περί Ψηφιακών Υπηρεσιών στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"subject": "Επαλήθευσε το email σου για αναφορά DSA",
|
||||
"body": "Γεια σου,\n\nΧρησιμοποίησε τον παρακάτω κωδικό επαλήθευσης για να υποβάλεις την αναφορά σου βάσει της Πράξης για τις ψηφιακές υπηρεσίες στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Επαλήθευσε το νέο σου email στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΕισήγαγε αυτόν τον κωδικό στην εφαρμογή για να επαληθεύσεις το νέο σου email στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΠληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή για να επαληθεύσεις το νέο σου email στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Επιβεβαίωσε την αλλαγή email στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για αλλαγή της διεύθυνσης email στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, ασφάλισε αμέσως τον λογαριασμό σου.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για αλλαγή της διεύθυνσης email στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, ασφάλισε αμέσως τον λογαριασμό σου.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Το email στο {product_name} άλλαξε",
|
||||
"body": "Γεια σου {username},\n\nΗ διεύθυνση email του λογαριασμού σου στο {product_name} άλλαξε σε {newEmail}.\n\nΕάν έκανες αυτήν την αλλαγή, δεν απαιτείται καμία ενέργεια. Εάν όχι, μπορείς να αναιρέσεις την αλλαγή και να ασφαλίσεις τον λογαριασμό σου χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{revertUrl}\n\nΑυτό θα επαναφέρει το προηγούμενο email σου, θα σε αποσυνδέσει από παντού, θα αφαιρέσει τους συνδεδεμένους αριθμούς τηλεφώνου, θα απενεργοποιήσει το MFA και θα απαιτήσει να ορίσεις έναν νέο κωδικό πρόσβασης.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"subject": "Το email σου στο {product_name} άλλαξε",
|
||||
"body": "Γεια σου {username},\n\nΗ διεύθυνση email του λογαριασμού σου στο {product_name} άλλαξε σε {newEmail}.\n\nΕάν έκανες εσύ αυτήν την αλλαγή, δεν χρειάζεται να κάνεις τίποτα. Εάν όχι, μπορείς να αναιρέσεις την αλλαγή και να ασφαλίσεις τον λογαριασμό σου με αυτόν τον σύνδεσμο:\n\n{revertUrl}\n\nΑυτό θα επαναφέρει το προηγούμενο email σου, θα σε αποσυνδέσει από παντού, θα αφαιρέσει τους συνδεδεμένους αριθμούς τηλεφώνου, θα απενεργοποιήσει το MFA και θα σου ζητήσει να ορίσεις νέο κωδικό πρόσβασης.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Επαλήθευσε τη διεύθυνση email σου στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΕπαλήθευσε τη διεύθυνση email για τον λογαριασμό σου στο {product_name} κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{verifyUrl}\n\nΕάν δεν δημιούργησες λογαριασμό στο {product_name}, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 24 ώρες.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΕπαλήθευσε τη διεύθυνση email για τον λογαριασμό σου στο {product_name} κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{verifyUrl}\n\nΕάν δεν δημιούργησες λογαριασμό στο {product_name}, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 24 ώρες.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Τα προνόμια από το δώρο που εξαργύρωσες έχουν αφαιρεθεί",
|
||||
"body": "Γεια σου {username},\n\nΈνας κωδικός δώρου που εξαργύρωσες πληρώθηκε αρχικά από κάποιον άλλο. Αυτή η πληρωμή έχει έκτοτε αντιστραφεί (αμφισβήτηση χρέωσης).\n\nΛόγω αυτού, αφαιρέσαμε τα προνόμια που προστέθηκαν στον λογαριασμό σου όταν εξαργύρωσες το δώρο.\n\nΕάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με την ομάδα υποστήριξής μας και συμπερίλαβε τυχόν λεπτομέρειες που έχεις σχετικά με τον κωδικό δώρου και πότε τον εξαργύρωσες.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΈνας κωδικός δώρου που εξαργύρωσες πληρώθηκε αρχικά από κάποιον άλλο. Αυτή η πληρωμή έχει έκτοτε αντιστραφεί (αμφισβήτηση χρέωσης).\n\nΓι' αυτόν τον λόγο, αφαιρέσαμε τα προνόμια που προστέθηκαν στον λογαριασμό σου όταν εξαργύρωσες το δώρο.\n\nΕάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με την ομάδα υποστήριξής μας και ανάφερε όσες λεπτομέρειες έχεις σχετικά με τον κωδικό δώρου και το πότε τον εξαργύρωσες.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Η εξαγωγή δεδομένων σου στο {product_name} είναι έτοιμη για λήψη",
|
||||
"body": "Γεια σου {username},\n\nΗ εξαγωγή δεδομένων σου είναι έτοιμη.\n\nΣύνδεσμος λήψης:\n{downloadUrl}\n\nΣυμπεριλαμβανόμενα μηνύματα: {totalMessages, number}\nΜέγεθος αρχείου: {fileSizeMB, number} MB\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτήν την εξαγωγή, άλλαξε αμέσως τον κωδικό πρόσβασής σου και επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΗ εξαγωγή δεδομένων σου είναι έτοιμη.\n\nΣύνδεσμος λήψης:\n{downloadUrl}\n\nΜηνύματα που περιλαμβάνονται: {totalMessages, number}\nΜέγεθος αρχείου: {fileSizeMB, number} MB\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτήν την εξαγωγή, άλλαξε αμέσως τον κωδικό πρόσβασής σου και επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί λόγω αδράνειας",
|
||||
"body": "Γεια σου {username},\n\nΔεν έχουμε δει καμία δραστηριότητα στον λογαριασμό σου στο {product_name} από τις {lastActiveDate, date, full}.\n\nΕάν δεν συνδεθείς μέχρι τις {deletionDate, date, full} στις {deletionDate, time, short}, ο λογαριασμός σου θα διαγραφεί οριστικά λόγω αδράνειας.\n\nΣυνδέσου εδώ:\n{loginUrl}\n\nΕάν χρησιμοποίησες το {product_name} πρόσφατα, επικοινώνησε αμέσως με την ομάδα υποστήριξής μας.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΔεν έχουμε δει καμία δραστηριότητα στον λογαριασμό σου στο {product_name} από τις {lastActiveDate, date, full}.\n\nΕάν δεν συνδεθείς μέχρι τις {deletionDate, date, full} και ώρα {deletionDate, time, short}, ο λογαριασμός σου θα διαγραφεί οριστικά λόγω αδράνειας.\n\nΣυνδέσου εδώ:\n{loginUrl}\n\nΕάν χρησιμοποίησες το {product_name} πρόσφατα, επικοινώνησε αμέσως με την ομάδα υποστήριξής μας.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Εξουσιοδότησε τη σύνδεση από μια νέα διεύθυνση IP",
|
||||
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από μια νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Επιβεβαίωσε την πρόσβαση στους εφεδρικούς κωδικούς σου στο {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}"
|
||||
"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_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}.\n\nΕάν δεν το ζήτησες, μπορεί κάποιος να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Επαναφορά του κωδικού πρόσβασής σου στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΖήτησες επαναφορά κωδικού πρόσβασης του {product_name}. Χρησιμοποίησε τον παρακάτω σύνδεσμο για να ορίσεις έναν νέο κωδικό πρόσβασης:\n\n{resetUrl}\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 1 ώρα.\n\n– Ομάδα {product_name}"
|
||||
"subject": "Επανάφερε τον κωδικό πρόσβασής σου στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΖήτησες επαναφορά του κωδικού πρόσβασής σου στο {product_name}. Χρησιμοποίησε τον παρακάτω σύνδεσμο για να ορίσεις νέο κωδικό πρόσβασης:\n\n{resetUrl}\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 1 ώρα.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "Η εγγραφή σου στο {product_name} έχει εγκριθεί",
|
||||
"body": "Γεια σου {username},\n\nΚαλά νέα: η εγγραφή σου στο {product_name} έχει εγκριθεί.\n\nΜπορείς τώρα να συνδεθείς στην εφαρμογή του {product_name} εδώ:\n{channelsUrl}\n\nΚαλώς ήρθες στην κοινότητα του {product_name}.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΚαλά νέα: η εγγραφή σου στο {product_name} έχει εγκριθεί.\n\nΜπορείς τώρα να συνδεθείς στην εφαρμογή του {product_name} εδώ:\n{channelsUrl}\n\nΚαλώς ήρθες στην κοινότητα του {product_name}.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Η αναφορά σου στο {product_name} έχει εξεταστεί",
|
||||
"body": "Γεια σου {username},\n\nΗ αναφορά σου (ID: {reportId}) έχει εξεταστεί από την Ομάδα Ασφαλείας μας.{hasComment, select, yes {\n\nΑπάντηση από την Ομάδα Ασφαλείας:\n{publicComment}} other {}}\n\nΕυχαριστούμε που βοηθάς να διατηρήσουμε το {product_name} ασφαλές για όλους. Λαμβάνουμε όλες τις αναφορές σοβαρά υπόψη και εκτιμούμε τη συμβολή σου στην κοινότητα.\n\nΕάν έχεις οποιεσδήποτε ερωτήσεις ή ανησυχίες σχετικά με αυτό το αποτέλεσμα, επικοινώνησε με το {safety_email}.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΗ αναφορά σου (ID: {reportId}) εξετάστηκε από την ομάδα ασφαλείας μας.{hasComment, select, yes {\n\nΑπάντηση από την ομάδα ασφαλείας:\n{publicComment}} other {}}\n\nΕυχαριστούμε που βοηθάς να παραμείνει το {product_name} ασφαλές για όλους. Λαμβάνουμε σοβαρά υπόψη κάθε αναφορά και εκτιμούμε τη συμβολή σου στην κοινότητα.\n\nΕάν έχεις ερωτήσεις ή απορίες σχετικά με αυτό το αποτέλεσμα, στείλε email στο {safety_email}.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} στις {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για ένα σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} είναι προγραμματισμένη",
|
||||
"body": "Γεια σου {username},\n\nΖήτησες τη διαγραφή του λογαριασμού σου στο {product_name}. Ο λογαριασμός σου έχει προγραμματιστεί για οριστική διαγραφή στις:\n\n{deletionDate, date, full} στις {deletionDate, time, short}\n\nΕάν δεν το ζήτησες, συνδέσου στον λογαριασμό σου για να ακυρώσεις τη διαγραφή. Σου συνιστούμε επίσης να αλλάξεις τον κωδικό πρόσβασής σου και να ασφαλίσεις τον λογαριασμό σου.\n\n– Ομάδα {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΖήτησες τη διαγραφή του λογαριασμού σου στο {product_name}. Ο λογαριασμός σου έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}\n\nΕάν δεν το ζήτησες, συνδέσου στον λογαριασμό σου για να ακυρώσεις τη διαγραφή. Σου προτείνουμε επίσης να αλλάξεις τον κωδικό πρόσβασής σου για να ασφαλίσεις τον λογαριασμό σου.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί",
|
||||
"body": "Γεια σου {username},\n\nΚαλά νέα: η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΜπορείς τώρα να συνδεθείς ξανά και να συνεχίσεις να χρησιμοποιείς το {product_name} κανονικά.\n\n– Ομάδα Ασφαλείας {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΚαλά νέα: η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΜπορείς τώρα να συνδεθείς ξανά και να συνεχίσεις να χρησιμοποιείς το {product_name} κανονικά.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Thank you for your {product_name} donation",
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Manage your {product_name} donations",
|
||||
@@ -56,8 +56,8 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hello {username},\n\nWe haven't seen any activity on your {product_name} account since {lastActiveDate, date, full}.\n\nIf you don't log in by {deletionDate, date, full} at {deletionDate, time, short}, your account will be permanently deleted due to inactivity.\n\nLog in here:\n{loginUrl}\n\nIf you've used {product_name} recently, please contact our support team right away.\n\n– {product_name} Team"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Authorize login from a new IP address",
|
||||
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
|
||||
"subject": "Authorise login from a new IP address",
|
||||
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorise this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirm access to your {product_name} backup codes",
|
||||
@@ -65,7 +65,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirm your {product_name} password change",
|
||||
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
|
||||
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Reset your {product_name} password",
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente debido a infracciones de nuestros Términos de Servicio o Normas de la Comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Normas de la Comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Proporciona cualquier evidencia o contexto relevante\n\nUn miembro del Equipo de Seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestros Términos del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por violar nuestros Términos de Servicio o Normas de la Comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Normas de la Comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestros Términos del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nStripe te enviará un recibo separado con tu factura en PDF en breve. Esto incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas, {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administra tus donaciones a {product_name}",
|
||||
"body": "Hola:\n\nHaz clic en el siguiente enlace para acceder a tu portal de donantes:\n\n{manageUrl}\n\nEn el portal, puedes administrar suscripciones, descargar facturas y ver tu historial de donaciones.\n\nEste enlace vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste este enlace, puedes ignorar este correo electrónico de forma segura.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifica tu correo electrónico para un informe de la Ley de Servicios Digitales",
|
||||
"body": "Hola:\n\nUsa el código de verificación a continuación para enviar tu informe de la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Verifica tu correo electrónico para enviar un reporte conforme a la Ley de Servicios Digitales",
|
||||
"body": "Hola:\n\nUsa el código de verificación a continuación para enviar tu reporte conforme a la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Verifica tu nuevo correo electrónico de {product_name}",
|
||||
"body": "Hola {username}:\n\nIngresa este código en la aplicación para verificar tu nuevo correo electrónico de {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
|
||||
"subject": "Verifica tu nuevo correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nIngresa este código en la aplicación para verificar tu nuevo correo electrónico en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Confirma tu cambio de correo electrónico de {product_name}",
|
||||
"subject": "Confirma tu cambio de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la dirección de correo electrónico 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, por favor, protege tu cuenta de inmediato.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Tu correo electrónico de {product_name} fue cambiado",
|
||||
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} se cambió a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Se cambió tu correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} se cambió a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Verifica tu dirección de correo electrónico de {product_name}",
|
||||
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nPor favor, verifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico de forma segura.\n\nEste enlace es válido por 24 horas.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
@@ -76,20 +76,20 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hola {username}:\n\nBuenas noticias: tu registro en {product_name} ha sido aprobado.\n\nAhora puedes iniciar sesión en la aplicación de {product_name} aquí:\n{channelsUrl}\n\nBienvenido a la comunidad de {product_name}.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Tu informe de {product_name} ha sido revisado",
|
||||
"body": "Hola {username}:\n\nTu informe (ID: {reportId}) ha sido revisado por nuestro Equipo de Seguridad.{hasComment, select, yes {\n\nRespuesta del Equipo de Seguridad:\n{publicComment}} other {}}\n\nGracias por ayudar a mantener {product_name} seguro para todos. Nos tomamos todos los informes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, comunícate con {safety_email}.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Se revisó tu reporte en {product_name}",
|
||||
"body": "Hola {username}:\n\nNuestro equipo de seguridad revisó tu reporte (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudar a mantener {product_name} seguro para todos. Nos tomamos todos los reportes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, comunícate con {safety_email}.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. Tu cuenta está programada para su eliminación permanente el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Se levantó la suspensión de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se levantó la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes iniciar sesión de nuevo y seguir usando {product_name} con normalidad.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se levantó la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes iniciar sesión de nuevo y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente debido a infracciones de nuestros Términos de Servicio o Directrices de la Comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Directrices de la Comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Proporciona cualquier evidencia o contexto relevante\n\nUn miembro del Equipo de Seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestras Condiciones del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por violar nuestros Términos de Servicio o Directrices de la Comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Directrices de la Comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestras Condiciones del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nCantidad: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nStripe te enviará un recibo separado con tu factura en PDF en breve. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestiona tus donaciones de {product_name}",
|
||||
"subject": "Gestiona tus donaciones a {product_name}",
|
||||
"body": "Hola:\n\nHaz clic en el siguiente enlace para acceder a tu portal de donantes:\n\n{manageUrl}\n\nEn el portal, puedes gestionar suscripciones, descargar facturas y ver tu historial de donaciones.\n\nEste enlace caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste este enlace, puedes ignorar este correo electrónico de forma segura.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifica tu correo electrónico para un informe de la DSA",
|
||||
"body": "Hola:\n\nUsa el siguiente código de verificación para enviar tu informe de la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Verifica tu correo electrónico para enviar una denuncia conforme a la Ley de Servicios Digitales",
|
||||
"body": "Hola:\n\nUsa el siguiente código de verificación para enviar tu denuncia conforme a la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Verifica tu nuevo correo electrónico para {product_name}",
|
||||
"body": "Hola {username}:\n\nIntroduce este código en la aplicación para verificar tu nuevo correo electrónico para {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
|
||||
"subject": "Verifica tu nuevo correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nIntroduce este código en la aplicación para verificar tu nuevo correo electrónico en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Confirma tu cambio de correo electrónico para {product_name}",
|
||||
"subject": "Confirma tu cambio de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la dirección de correo electrónico 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, protege tu cuenta de inmediato.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Tu correo electrónico para {product_name} ha sido cambiado",
|
||||
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} ha sido cambiada a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Se ha cambiado tu correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} ha sido cambiada a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Verifica tu dirección de correo electrónico para {product_name}",
|
||||
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nVerifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico.\n\nEste enlace es válido durante 24 horas.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
@@ -56,8 +56,8 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hola {username}:\n\nNo hemos visto ninguna actividad en tu cuenta de {product_name} desde el {lastActiveDate, date, full}.\n\nSi no inicias sesión antes del {deletionDate, date, full} a las {deletionDate, time, short}, tu cuenta será eliminada permanentemente debido a la inactividad.\n\nInicia sesión aquí:\n{loginUrl}\n\nSi has usado {product_name} recientemente, ponte en contacto con nuestro equipo de soporte de inmediato.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Autorizar inicio de sesión desde una nueva dirección IP",
|
||||
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
|
||||
"subject": "Autoriza el inicio de sesión desde una nueva dirección IP",
|
||||
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva dirección IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
|
||||
@@ -76,20 +76,20 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hola {username}:\n\nBuenas noticias: tu registro en {product_name} ha sido aprobado.\n\nAhora puedes iniciar sesión en la aplicación de {product_name} aquí:\n{channelsUrl}\n\nBienvenido a la comunidad de {product_name}.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Tu informe en {product_name} ha sido revisado",
|
||||
"body": "Hola {username}:\n\nTu informe (ID: {reportId}) ha sido revisado por nuestro Equipo de Seguridad.{hasComment, select, yes {\n\nRespuesta del Equipo de Seguridad:\n{publicComment}} other {}}\n\nGracias por ayudarnos a mantener {product_name} seguro para todos. Nos tomamos todos los informes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con {safety_email}.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"subject": "Se ha revisado tu denuncia en {product_name}",
|
||||
"body": "Hola {username}:\n\nNuestro equipo de seguridad ha revisado tu denuncia (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudarnos a mantener {product_name} seguro para todos. Nos tomamos todas las denuncias en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con {safety_email}.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. Tu cuenta está programada para su eliminación permanente el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Se ha levantado la suspensión de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se ha levantado la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes volver a iniciar sesión y seguir usando {product_name} con normalidad.\n\n– Equipo de Seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se ha levantado la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes volver a iniciar sesión y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
@@ -9,23 +9,23 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on ajoitettu poistettavaksi pysyvästi käyttöehtojemme tai yhteisön sääntöjemme rikkomusten vuoksi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\nTarkista:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nValitusprosessi:\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, sinulla on 60 päivää aikaa tehdä valitus. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.\n\nValituksessasi:\n- Selitä selkeästi, miksi uskot päätöksen olleen virheellinen tai perusteeton\n- Anna kaikki asiaankuuluvat todisteet tai konteksti\n\n{product_name}-turvallisuustiimin jäsen tarkistaa valituksesi ja voi keskeyttää vireillä olevan poiston, kunnes lopullinen päätös on tehty.\n\n– {product_name}-turvallisuustiimi"
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on ajoitettu poistettavaksi pysyvästi käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\nTutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nValitusprosessi:\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, sinulla on 60 päivää aikaa tehdä valitus. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.\n\nValituksessasi:\n- Selitä selkeästi, miksi uskot päätöksen olleen virheellinen tai perusteeton\n- Liitä mukaan asiaankuuluvat todisteet tai taustatiedot\n\n{product_name}-turvallisuustiimin jäsen käsittelee valituksesi ja voi keskeyttää vireillä olevan poiston, kunnes lopullinen päätös on tehty.\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa on tilapäisesti jäädytetty",
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on jäädytetty tilapäisesti käyttöehtojemme tai yhteisön sääntöjemme rikkomisen vuoksi.\n\nKesto: {durationHours, plural,\n =1 {1 tunti}\n other {# tuntia}\n}\nJäädytettynä {bannedUntil, date, full} klo {bannedUntil, time, short} asti\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTänä aikana et voi käyttää tiliäsi.\n\nTarkista:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, voit tehdä valituksen. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen. Tarkistamme valituksesi ja vastaamme päätöksellämme.\n\n– {product_name}-turvallisuustiimi"
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on jäädytetty tilapäisesti käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nKesto: {durationHours, plural,\n =1 {1 tunti}\n other {# tuntia}\n}\nJäädytettynä {bannedUntil, date, full} klo {bannedUntil, time, short} asti\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTänä aikana et voi käyttää tiliäsi.\n\nTutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, voit tehdä valituksen. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen. Käsittelemme valituksesi ja ilmoitamme sinulle päätöksemme.\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Kiitos {product_name}-lahjoituksestasi",
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {asetettu}\n year {asetettu}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle erillisen kuitin PDF-laskun kanssa pian. Tämä sisältää kaikki maksutiedot ja sitä voidaan käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Hallinnoi {product_name}-palvelun lahjoituksiasi",
|
||||
"subject": "Hallitse {product_name}-lahjoituksiasi",
|
||||
"body": "Hei,\n\nNapsauta alla olevaa linkkiä päästäksesi lahjoittajaportaaliisi:\n\n{manageUrl}\n\nPortaalissa voit hallinnoida tilauksia, ladata laskuja ja tarkastella lahjoitushistoriaasi.\n\nTämä linkki vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä linkkiä, voit jättää tämän sähköpostin turvallisesti huomiotta.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Vahvista sähköpostiosoite DSA-raporttia varten",
|
||||
"body": "Hei,\n\nKäytä alla olevaa vahvistuskoodia lähettääksesi DSA-raporttisi {product_name}-palveluun:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, voit jättää tämän sähköpostin huomiotta.\n\n– {product_name}-turvallisuustiimi"
|
||||
"subject": "Vahvista sähköpostiosoitteesi DSA-ilmoitusta varten",
|
||||
"body": "Hei,\n\nKäytä alla olevaa vahvistuskoodia lähettääksesi digipalvelusäädöksen mukaisen ilmoituksen {product_name}-palveluun:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, voit jättää tämän sähköpostin huomiotta.\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Vahvista uusi sähköpostiosoitteesi {product_name}-tilillä",
|
||||
@@ -33,7 +33,7 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Vahvista sähköpostiosoitteen muutos {product_name}-tilillä",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa sähköpostiosoitetta {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, turvaa tilisi välittömästi.\n\n– {product_name}-tiimi"
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa sähköpostiosoitetta {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ä, turvaa tilisi välittömästi.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "{product_name}-tilisi sähköpostiosoite on vaihdettu",
|
||||
@@ -57,15 +57,15 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Hyväksy kirjautuminen uudesta IP-osoitteesta",
|
||||
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos tämä olit sinä, valtuuta tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
|
||||
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos yritit kirjautua sisään, hyväksy tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Vahvista pääsy {product_name}-tilisi varmuuskoodeihin",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varmuuskoodeja {product_name}-tililläsi.\n\nVahvistaaksesi tämän pyynnön, syötä tämä koodi sovellukseen:\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.\n\n– {product_name}-tiimi"
|
||||
"subject": "Vahvista pääsy {product_name}-tilisi varakoodeihin",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varakoodeja {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi pyynnön:\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.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Vahvista salasanan muutos {product_name}-tilillä",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\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}.\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",
|
||||
@@ -76,8 +76,8 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hei {username},\n\nHyviä uutisia: {product_name}-rekisteröitymisesi on hyväksytty.\n\nVoit nyt kirjautua {product_name}-sovellukseen täältä:\n{channelsUrl}\n\nTervetuloa {product_name}-yhteisöön.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "{product_name}-raporttisi on tarkistettu",
|
||||
"body": "Hei {username},\n\nTurvallisuustiimimme on tarkistanut raporttisi (ID: {reportId}).{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nKiitos, että autat pitämään {product_name}-palvelun turvallisena kaikille. Otamme kaikki raportit vakavasti ja arvostamme panostasi yhteisöön.\n\nJos sinulla on kysyttävää tai huolenaiheita tästä tuloksesta, ota yhteyttä osoitteeseen {safety_email}.\n\n– {product_name}-turvallisuustiimi"
|
||||
"subject": "{product_name}-ilmoituksesi on käsitelty",
|
||||
"body": "Hei {username},\n\nTurvallisuustiimimme on käsitellyt ilmoituksesi (ID: {reportId}).{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nKiitos, että autat pitämään {product_name}-palvelun turvallisena kaikille. Otamme kaikki ilmoitukset vakavasti ja arvostamme panostasi yhteisöön.\n\nJos sinulla on kysyttävää tai huolenaiheita tästä päätöksestä, ota yhteyttä osoitteeseen {safety_email}.\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Ton compte {product_name} a été temporairement désactivé",
|
||||
"body": "Bonjour {username},\n\nNous avons temporairement désactivé ton compte {product_name} car nous avons détecté une activité suspecte.\n\n{reason, select,\n null {}\n other {Raison : {reason}}\n}\n\nPour retrouver l'accès à ton compte, tu devras réinitialiser ton mot de passe :\n\n{forgotUrl}\n\nAprès avoir réinitialisé ton mot de passe, tu pourras te connecter à nouveau.\n\nSi tu penses qu'il s'agit d'une erreur, contacte notre équipe d'assistance.\n\n– Équipe de sécurité de {product_name}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Ton compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de ton compte {product_name} a été programmée en raison de violations de nos Conditions d'utilisation ou de nos Règles de la communauté.\n\nSuppression programmée : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Raison : {reason}}\n}\n\nIl s'agit d'une mesure sérieuse. Les données de ton compte seront définitivement supprimées à la date prévue.\n\nConsulte :\n- Conditions d'utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\nProcédure d'appel :\nSi tu penses que cette décision était incorrecte ou injustifiée, tu as 60 jours pour soumettre un appel. Envoie un e-mail à {appeals_email} depuis cette adresse e-mail.\n\nDans ton appel :\n- Explique clairement pourquoi tu penses que la décision était incorrecte ou injustifiée\n- Fournis toute preuve ou contexte pertinent\n\nUn membre de l'équipe de sécurité de {product_name} examinera ton appel et pourra suspendre la suppression en attente jusqu'à ce qu'une décision finale soit prise.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Votre compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de votre compte {product_name} a été programmée en raison de violations de nos conditions d’utilisation ou des règles de la communauté.\n\nSuppression prévue : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select, null {} other {Motif : {reason}}}\n\nIl s’agit d’une sanction importante. Les données de votre compte seront définitivement supprimées à la date prévue.\n\nVeuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\nProcédure de recours :\nSi vous estimez que cette décision est incorrecte ou injustifiée, vous disposez de 60 jours pour déposer un recours. Écrivez à {appeals_email} depuis cette adresse e-mail.\n\nDans votre recours :\n- Expliquez clairement pourquoi vous estimez la décision incorrecte ou injustifiée\n- Fournissez tout élément de preuve ou de contexte pertinent\n\nUn membre de l’équipe de sécurité de {product_name} examinera votre recours et pourra suspendre la suppression prévue jusqu’à la décision finale.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Ton compte {product_name} a été temporairement suspendu",
|
||||
"body": "Bonjour {username},\n\nTon compte {product_name} a été temporairement suspendu pour avoir enfreint nos Conditions d'utilisation ou nos Règles de la communauté.\n\nDurée : {durationHours, plural,\n =1 {1 heure}\n other {# heures}\n}\nSuspendu jusqu'au : {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Raison : {reason}}\n}\n\nPendant cette période, tu ne pourras pas accéder à ton compte.\n\nConsulte :\n- Conditions d'utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\nSi tu penses que cette décision était incorrecte ou injustifiée, tu peux faire appel. Envoie un e-mail à {appeals_email} depuis cette adresse e-mail et explique clairement pourquoi tu penses que la décision était incorrecte. Nous examinerons ton appel et te répondrons avec notre décision.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Votre compte {product_name} a été temporairement suspendu",
|
||||
"body": "Bonjour {username},\n\nVotre compte {product_name} a été temporairement suspendu pour violation de nos conditions d’utilisation ou des règles de la communauté.\n\nDurée : {durationHours, plural, =1 {1 heure} other {# heures}}\nSuspension jusqu’au : {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select, null {} other {Motif : {reason}}}\n\nVous ne pourrez pas accéder à votre compte pendant cette période.\n\nVeuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\nSi vous estimez que cette décision est incorrecte ou injustifiée, vous pouvez déposer un recours. Écrivez à {appeals_email} depuis cette adresse e-mail et expliquez clairement pourquoi vous contestez cette décision. Nous examinerons votre recours et vous communiquerons notre décision.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Merci pour ton don à {product_name}",
|
||||
"body": "Bonjour,\n\nMerci pour ton don à {product_name} ! Ton {interval, select,\n month {don récurrent}\n year {don récurrent}\n other {don unique}\n} a été {interval, select,\n month {mis en place}\n year {mis en place}\n other {traité}\n} avec succès.\n\nDétails du don :\nMontant : {amount} {currency} {interval, select,\n month {par mois}\n year {par an}\n other {}\n}\n\nStripe t'enverra sous peu un reçu séparé avec ta facture PDF. Cela inclut tous les détails de paiement et peut être utilisé à des fins fiscales.\n\nTu peux consulter l'historique de tes dons, télécharger des factures, {interval, select,\n month {et gérer ou annuler ton abonnement}\n year {et gérer ou annuler ton abonnement}\n other {et gérer les futurs dons}\n} à tout moment en utilisant ce lien :\n\n{manageUrl}\n\nTon soutien aide à maintenir {product_name} en fonctionnement. Merci !\n\n– L'équipe {product_name}"
|
||||
"subject": "Merci pour votre don à {product_name}",
|
||||
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gérer tes dons {product_name}",
|
||||
"body": "Bonjour,\n\nClique sur le lien ci-dessous pour accéder à ton portail de donateur :\n\n{manageUrl}\n\nDans le portail, tu peux gérer tes abonnements, télécharger des factures et consulter l'historique de tes dons.\n\nCe lien expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé ce lien, tu peux ignorer cet e-mail en toute sécurité.\n\n– L'équipe {product_name}"
|
||||
"subject": "Gérer vos dons à {product_name}",
|
||||
"body": "Bonjour,\n\nCliquez sur le lien ci-dessous pour accéder à votre portail de donateur :\n\n{manageUrl}\n\nVous pouvez y gérer vos abonnements, télécharger des factures et consulter l’historique de vos dons.\n\nCe lien expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi vous n’avez pas demandé ce lien, vous pouvez ignorer cet e-mail.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Vérifie ton e-mail pour un signalement DSA",
|
||||
"body": "Bonjour,\n\nUtilise le code de vérification ci-dessous pour soumettre ton signalement DSA sur {product_name} :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, tu peux ignorer cet e-mail.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Vérifiez votre e-mail pour un signalement DSA",
|
||||
"body": "Bonjour,\n\nUtilisez le code ci-dessous pour envoyer votre signalement au titre du règlement sur les services numériques (DSA) sur {product_name} :\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, vous pouvez ignorer cet e-mail.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "Vérifie ta nouvelle adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nSaisis ce code dans l'application pour vérifier ta nouvelle adresse e-mail {product_name} :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, tu peux ignorer cet e-mail.\n\n– L'équipe {product_name}"
|
||||
"subject": "Vérifiez votre nouvelle adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nSaisissez ce code dans l’application pour vérifier votre nouvelle adresse e-mail {product_name} :\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, vous pouvez ignorer cet e-mail.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Confirme ton changement d'e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification de l'adresse e-mail de ton compte {product_name}.\n\nPour confirmer ce changement, saisis ce code dans l'application :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, sécurise ton compte immédiatement.\n\n– L'équipe {product_name}"
|
||||
"subject": "Confirmez votre changement d’adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification de l’adresse e-mail 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, sécurisez votre compte immédiatement.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Ton e-mail {product_name} a été modifié",
|
||||
"body": "Bonjour {username},\n\nL'adresse e-mail de ton compte {product_name} a été modifiée pour {newEmail}.\n\nSi tu as effectué ce changement, aucune action n'est nécessaire. Si ce n'est pas le cas, tu peux annuler le changement et sécuriser ton compte en utilisant ce lien :\n\n{revertUrl}\n\nCela restaurera ton e-mail précédent, te déconnectera de toutes les sessions, supprimera les numéros de téléphone liés, désactivera l'authentification multifacteur et t'obligera à définir un nouveau mot de passe.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Votre adresse e-mail {product_name} a été modifiée",
|
||||
"body": "Bonjour {username},\n\nL’adresse e-mail de votre compte {product_name} a été remplacée par {newEmail}.\n\nSi vous avez effectué ce changement, vous n’avez rien à faire. Sinon, vous pouvez l’annuler et sécuriser votre compte avec ce lien :\n\n{revertUrl}\n\nVotre ancienne adresse e-mail sera rétablie, toutes vos sessions seront fermées, les numéros de téléphone associés seront supprimés et l’authentification multifacteur sera désactivée. Vous devrez aussi définir un nouveau mot de passe.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Vérifie ton adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nVérifie l'adresse e-mail de ton compte {product_name} en cliquant sur le lien ci-dessous :\n\n{verifyUrl}\n\nSi tu n'as pas créé de compte {product_name}, tu peux ignorer cet e-mail en toute sécurité.\n\nCe lien est valide pendant 24 heures.\n\n– L'équipe {product_name}"
|
||||
"subject": "Vérifiez votre adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nVérifiez l’adresse e-mail de votre compte {product_name} en cliquant sur le lien ci-dessous :\n\n{verifyUrl}\n\nSi vous n’avez pas créé de compte {product_name}, vous pouvez ignorer cet e-mail.\n\nCe lien est valable pendant 24 heures.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Les avantages de ton cadeau échangé ont été supprimés",
|
||||
"body": "Bonjour {username},\n\nUn code cadeau que tu as échangé a été initialement payé par quelqu'un d'autre. Ce paiement a depuis été annulé (une rétrofacturation).\n\nPour cette raison, nous avons supprimé les avantages qui ont été ajoutés à ton compte lorsque tu as échangé le cadeau.\n\nSi tu penses qu'il s'agit d'une erreur, contacte notre équipe d'assistance et inclus tous les détails que tu as concernant le code cadeau et le moment où tu l'as échangé.\n\n– L'équipe {product_name}"
|
||||
"subject": "Les avantages de votre cadeau activé ont été retirés",
|
||||
"body": "Bonjour {username},\n\nUn code cadeau que vous avez utilisé avait été payé par une autre personne. Ce paiement a depuis été annulé à la suite d’une contestation bancaire.\n\nNous avons donc retiré les avantages ajoutés à votre compte lors de l’activation du cadeau.\n\nSi vous pensez qu’il s’agit d’une erreur, contactez notre équipe d’assistance et indiquez les informations dont vous disposez sur le code cadeau et sa date d’utilisation.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Ton export de données {product_name} est prêt à être téléchargé",
|
||||
"body": "Bonjour {username},\n\nTon export de données est prêt.\n\nLien de téléchargement :\n{downloadUrl}\n\nMessages inclus : {totalMessages, number}\nTaille du fichier : {fileSizeMB, number} Mo\n\nCe lien expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cet export, change ton mot de passe immédiatement et contacte notre équipe d'assistance.\n\n– L'équipe {product_name}"
|
||||
"subject": "Votre export de données {product_name} est prêt à être téléchargé",
|
||||
"body": "Bonjour {username},\n\nVotre export de données est prêt.\n\nLien de téléchargement :\n{downloadUrl}\n\nMessages inclus : {totalMessages, number}\nTaille du fichier : {fileSizeMB, number} Mo\n\nCe lien expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi vous n’avez pas demandé cet export, changez immédiatement votre mot de passe et contactez notre équipe d’assistance.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Ton compte {product_name} sera supprimé en raison d'inactivité",
|
||||
"body": "Bonjour {username},\n\nNous n'avons détecté aucune activité sur ton compte {product_name} depuis le {lastActiveDate, date, full}.\n\nSi tu ne te connectes pas avant le {deletionDate, date, full} à {deletionDate, time, short}, ton compte sera définitivement supprimé en raison d'inactivité.\n\nConnecte-toi ici :\n{loginUrl}\n\nSi tu as utilisé {product_name} récemment, contacte notre équipe d'assistance immédiatement.\n\n– L'équipe {product_name}"
|
||||
"subject": "Votre compte {product_name} sera supprimé pour inactivité",
|
||||
"body": "Bonjour {username},\n\nNous n’avons détecté aucune activité sur votre compte {product_name} depuis le {lastActiveDate, date, full}.\n\nSi vous ne vous connectez pas avant le {deletionDate, date, full} à {deletionDate, time, short}, votre compte sera définitivement supprimé pour inactivité.\n\nConnectez-vous ici :\n{loginUrl}\n\nSi vous avez utilisé {product_name} récemment, contactez immédiatement notre équipe d’assistance.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Autoriser la connexion depuis une nouvelle adresse IP",
|
||||
"body": "Bonjour {username},\n\nNous avons détecté une tentative de connexion à ton compte {product_name} depuis une nouvelle adresse IP :\n\nAdresse IP : {ipAddress}\nLocalisation : {location}\n\nSi c'était toi, autorise cette adresse IP en cliquant sur le lien ci-dessous :\n\n{authUrl}\n\nSi tu n'as pas tenté de te connecter, change ton mot de passe immédiatement.\n\nCe lien est valide pendant 30 minutes.\n\n– L'équipe {product_name}"
|
||||
"body": "Bonjour {username},\n\nNous avons détecté une tentative de connexion à votre compte {product_name} depuis une nouvelle adresse IP :\n\nAdresse IP : {ipAddress}\nLocalisation : {location}\n\nSi vous êtes à l’origine de cette tentative, autorisez cette adresse IP en cliquant sur le lien ci-dessous :\n\n{authUrl}\n\nSinon, changez immédiatement votre mot de passe.\n\nCe lien est valable pendant 30 minutes.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirme l'accès à tes codes de secours {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de consultation des codes de secours de ton compte {product_name}.\n\nPour confirmer cette demande, saisis ce code dans l'application :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement.\n\n– L'équipe {product_name}"
|
||||
"subject": "Confirmez l’accès à vos codes de secours {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de consultation des codes de secours de votre compte {product_name}.\n\nPour confirmer cette demande, 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.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirme la modification de ton mot de passe {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification du mot de passe de ton compte {product_name}.\n\nPour confirmer cette modification, saisis ce code dans l'application :\n\n{code}\n\nCe code expire à {expiresAt}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement et active l'authentification à deux facteurs.\n\n– L'équipe {product_name}"
|
||||
"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}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Réinitialiser ton mot de passe {product_name}",
|
||||
"body": "Bonjour {username},\n\nTu as demandé une réinitialisation de ton mot de passe {product_name}. Utilise le lien ci-dessous pour définir un nouveau mot de passe :\n\n{resetUrl}\n\nSi tu n'as pas demandé cela, tu peux ignorer cet e-mail en toute sécurité.\n\nCe lien est valide pendant 1 heure.\n\n– L'équipe {product_name}"
|
||||
"subject": "Réinitialiser votre mot de passe {product_name}",
|
||||
"body": "Bonjour {username},\n\nVous avez demandé la réinitialisation de votre mot de passe {product_name}. Utilisez le lien ci-dessous pour en définir un nouveau :\n\n{resetUrl}\n\nSi vous n’êtes pas à l’origine de cette demande, vous pouvez ignorer cet e-mail.\n\nCe lien est valable pendant 1 heure.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "Ton inscription {product_name} a été approuvée",
|
||||
"body": "Bonjour {username},\n\nBonne nouvelle : ton inscription {product_name} a été approuvée.\n\nTu peux maintenant te connecter à l'application {product_name} ici :\n{channelsUrl}\n\nBienvenue dans la communauté {product_name}.\n\n– L'équipe {product_name}"
|
||||
"subject": "Votre inscription à {product_name} a été approuvée",
|
||||
"body": "Bonjour {username},\n\nBonne nouvelle : votre inscription à {product_name} a été approuvée.\n\nVous pouvez maintenant vous connecter à l’application {product_name} ici :\n{channelsUrl}\n\nBienvenue dans la communauté {product_name}.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Ton signalement {product_name} a été examiné",
|
||||
"body": "Bonjour {username},\n\nTon signalement (ID : {reportId}) a été examiné par notre équipe de sécurité.{hasComment, select, yes {\n\nRéponse de l'équipe de sécurité :\n{publicComment}} other {}}\n\nMerci de nous aider à préserver la sécurité de {product_name} pour tous. Nous prenons tous les signalements au sérieux et apprécions ta contribution à la communauté.\n\nSi tu as des questions ou des préoccupations concernant ce résultat, contacte {safety_email}.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Votre signalement sur {product_name} a été examiné",
|
||||
"body": "Bonjour {username},\n\nVotre signalement (ID : {reportId}) a été examiné par notre équipe de sécurité.{hasComment, select, yes {\n\nRéponse de l’équipe de sécurité :\n{publicComment}} other {}}\n\nMerci de nous aider à préserver la sécurité de {product_name} pour tous. Nous prenons tous les signalements au sérieux et apprécions votre contribution à la communauté.\n\nPour toute question ou préoccupation concernant cette décision, contactez {safety_email}.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Ton compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de ton compte {product_name} a été programmée.\n\nSuppression programmée : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Raison : {reason}}\n}\n\nIl s'agit d'une mesure sérieuse. Les données de ton compte seront définitivement supprimées à la date prévue.\n\nSi tu penses que cette décision était incorrecte, tu peux soumettre un appel. Envoie un e-mail à {appeals_email} depuis cette adresse e-mail.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "Votre compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de votre compte {product_name} a été programmée.\n\nSuppression prévue : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select, null {} other {Motif : {reason}}}\n\nIl s’agit d’une sanction importante. Les données de votre compte seront définitivement supprimées à la date prévue.\n\nSi vous estimez que cette décision est incorrecte, vous pouvez déposer un recours. Écrivez à {appeals_email} depuis cette adresse e-mail.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La suppression de ton compte {product_name} est programmée",
|
||||
"body": "Bonjour {username},\n\nTu as demandé la suppression de ton compte {product_name}. Ton compte est programmé pour une suppression définitive le :\n\n{deletionDate, date, full} à {deletionDate, time, short}\n\nSi tu n'as pas demandé cela, connecte-toi à ton compte pour annuler la suppression. Nous te recommandons également de changer ton mot de passe pour sécuriser ton compte.\n\n– L'équipe {product_name}"
|
||||
"subject": "La suppression de votre compte {product_name} est programmée",
|
||||
"body": "Bonjour {username},\n\nVous avez demandé la suppression de votre compte {product_name}. Sa suppression définitive est prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}\n\nSi vous n’êtes pas à l’origine de cette demande, connectez-vous à votre compte pour annuler la suppression. Nous vous recommandons également de changer votre mot de passe pour sécuriser votre compte.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "La suspension de ton compte {product_name} a été levée",
|
||||
"body": "Bonjour {username},\n\nBonne nouvelle : la suspension de ton compte {product_name} a été levée.\n\n{reason, select,\n null {}\n other {Raison : {reason}}\n}\n\nTu peux maintenant te reconnecter et continuer à utiliser {product_name} normalement.\n\n– Équipe de sécurité de {product_name}"
|
||||
"subject": "La suspension de votre compte {product_name} a été levée",
|
||||
"body": "Bonjour {username},\n\nBonne nouvelle : la suspension de votre compte {product_name} a été levée.\n\n{reason, select, null {} other {Motif : {reason}}}\n\nVous pouvez maintenant vous reconnecter et utiliser {product_name} normalement.\n\n– L’équipe de sécurité de {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק לצמיתות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלך נקבע למחיקה לצמיתות עקב הפרות של תנאי השירות או הנחיות הקהילה שלנו.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nזוהי פעולת אכיפה חמורה. נתוני החשבון שלך יימחקו לצמיתות בתאריך המתוכנן.\n\nאנא עיינו ב:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\nתהליך ערעור:\nאם אתם חושבים שהחלטת אכיפה זו שגויה או בלתי מוצדקת, יש לכם 60 יום להגיש ערעור. שלחו דוא\"ל לכתובת {appeals_email} מכתובת דוא\"ל זו.\n\nבערעור:\n- הסבירו בבירור מדוע אתם חושבים שהחלטת האכיפה שגויה או בלתי מוצדקת\n- ספקו כל ראיה או הקשר רלוונטיים\n\nחבר בצוות הבטיחות של {product_name} יבדוק את ערעורכם ועשוי להשהות את המחיקה הממתינה עד לקבלת החלטה סופית.\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם נקבע למחיקה לצמיתות עקב הפרות של תנאי השירות או הנחיות הקהילה שלנו.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nזוהי פעולת אכיפה חמורה. נתוני החשבון שלכם יימחקו לצמיתות בתאריך המתוכנן.\n\nאנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\nתהליך ערעור:\nאם אתם חושבים שהחלטת האכיפה שגויה או בלתי מוצדקת, יש לכם 60 יום להגיש ערעור. שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו.\n\nבערעור:\n- הסבירו בבירור מדוע לדעתכם החלטת האכיפה שגויה או בלתי מוצדקת\n- צרפו כל ראיה או הקשר רלוונטיים\n\nחבר בצוות הבטיחות של {product_name} יבדוק את הערעור שלכם ועשוי להשהות את המחיקה הממתינה עד לקבלת החלטה סופית.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "חשבון ה-{product_name} שלך הושעה זמנית",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלך הושעה זמנית עקב הפרה של תנאי השירות או הנחיות הקהילה שלנו.\n\nמשך ההשעיה: {durationHours, plural,\n =1 {שעה אחת}\n other {# שעות}\n}\nהושעה עד: {bannedUntil, date, full} בשעה {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nבמהלך תקופה זו, לא תהיה לכם גישה לחשבון.\n\nאנא עיינו ב:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\nאם אתם חושבים שהחלטת אכיפה זו שגויה או בלתי מוצדקת, אתם יכולים להגיש ערעור. שלחו דוא\"ל לכתובת {appeals_email} מכתובת דוא\"ל זו והסבירו בבירור מדוע אתם חושבים שההחלטה שגויה. אנו נבדוק את ערעורכם ונשיב עם החלטתנו.\n\nצוות הבטיחות של {product_name}"
|
||||
"subject": "חשבון ה-{product_name} שלכם הושעה זמנית",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם הושעה זמנית עקב הפרה של תנאי השירות או הנחיות הקהילה שלנו.\n\nמשך ההשעיה: {durationHours, plural,\n =1 {שעה אחת}\n other {# שעות}\n}\nמושעה עד: {bannedUntil, date, full} בשעה {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nבמהלך תקופה זו לא תהיה לכם גישה לחשבון.\n\nאנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\nאם אתם חושבים שהחלטת האכיפה שגויה או בלתי מוצדקת, תוכלו להגיש ערעור. שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו והסבירו בבירור מדוע לדעתכם ההחלטה שגויה. נבדוק את הערעור ונעדכן אתכם בהחלטה שלנו.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "תודה על תרומתכם ל-{product_name}",
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {עובדה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {currency} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "ניהול התרומות שלכם ל-{product_name}",
|
||||
"body": "שלום,\n\nלחצו על הקישור למטה כדי לגשת לפורטל התורמים שלכם:\n\n{manageUrl}\n\nבפורטל, אתם יכולים לנהל מנויים, להוריד חשבוניות ולצפות בהיסטוריית התרומות שלכם.\n\nקישור זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם קישור זה, אתם יכולים להתעלם בבטחה מדוא\"ל זה.\n\nצוות {product_name}"
|
||||
"body": "שלום,\n\nלחצו על הקישור למטה כדי לגשת לפורטל התורמים שלכם:\n\n{manageUrl}\n\nבפורטל, אתם יכולים לנהל מנויים, להוריד חשבוניות ולצפות בהיסטוריית התרומות שלכם.\n\nקישור זה יפוג בתאריך {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם קישור זה, אתם יכולים להתעלם בבטחה מאימייל זה.\n\nצוות {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "אימות האימייל שלכם עבור דוח DSA",
|
||||
"body": "שלום,\n\nהשתמשו בקוד האימות למטה כדי להגיש את דוח חוק השירותים הדיגיטליים ב-{product_name}:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם מדוא\"ל זה.\n\nצוות הבטיחות של {product_name}"
|
||||
"subject": "אימות האימייל שלכם לדיווח DSA",
|
||||
"body": "שלום,\n\nהשתמשו בקוד האימות למטה כדי להגיש את הדיווח שלכם לפי חוק השירותים הדיגיטליים ב-{product_name}:\n\n{code}\n\nקוד זה יפוג בתאריך {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם מאימייל זה.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "אימות האימייל החדש שלכם ל-{product_name}",
|
||||
"body": "שלום {username},\n\nהזינו קוד זה באפליקציה כדי לאמת את האימייל החדש שלכם ב-{product_name}:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם מדוא\"ל זה.\n\nצוות {product_name}"
|
||||
"subject": "אימות האימייל החדש שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nהזינו קוד זה באפליקציה כדי לאמת את האימייל החדש שלכם ב-{product_name}:\n\n{code}\n\nקוד זה יפוג בתאריך {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם מאימייל זה.\n\nצוות {product_name}"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "אישור שינוי האימייל שלכם ל-{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}"
|
||||
"subject": "אישור שינוי האימייל שלכם ב-{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}"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "האימייל שלכם ב-{product_name} שונה",
|
||||
"body": "שלום {username},\n\nכתובת האימייל בחשבון ה-{product_name} שלכם שונתה ל- {newEmail}.\n\nאם אתם ביצעתם שינוי זה, אין צורך בפעולה. אם לא, אתם יכולים לבטל את השינוי ולאבטח את חשבונכם באמצעות קישור זה:\n\n{revertUrl}\n\nפעולה זו תשחזר את האימייל הקודם שלכם, תנתק אתכם מכל המכשירים, תסיר מספרי טלפון מקושרים, תבטל אימות רב-שלבי (MFA), ותדרוש מכם להגדיר סיסמה חדשה.\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום {username},\n\nכתובת האימייל בחשבון ה-{product_name} שלכם שונתה ל-{newEmail}.\n\nאם אתם ביצעתם שינוי זה, אין צורך בפעולה. אם לא, אתם יכולים לבטל את השינוי ולאבטח את חשבונכם באמצעות קישור זה:\n\n{revertUrl}\n\nפעולה זו תשחזר את האימייל הקודם שלכם, תנתק אתכם מכל המכשירים, תסיר מספרי טלפון מקושרים, תבטל את האימות הרב-שלבי (MFA), ותדרוש מכם להגדיר סיסמה חדשה.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "אימות האימייל שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nאנא אמת/י את כתובת האימייל עבור חשבון ה-{product_name} שלך על ידי לחיצה על הקישור למטה:\n\n{verifyUrl}\n\nאם לא יצרתם חשבון {product_name}, אתם יכולים להתעלם בבטחה מדוא\"ל זה.\n\nקישור זה תקף למשך 24 שעות.\n\nצוות {product_name}"
|
||||
"body": "שלום {username},\n\nאנא אמתו את כתובת האימייל של חשבון ה-{product_name} שלכם באמצעות לחיצה על הקישור למטה:\n\n{verifyUrl}\n\nאם לא יצרתם חשבון {product_name}, אתם יכולים להתעלם בבטחה מאימייל זה.\n\nקישור זה תקף למשך 24 שעות.\n\nצוות {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "הטבות מהמתנה שמימשתם הוסרו",
|
||||
"body": "שלום {username},\n\nקוד מתנה שמימשתם שולם במקור על ידי מישהו אחר. תשלום זה בוטל מאז (החזר חיוב).\n\nבשל כך, הסרנו את ההטבות שנוספו לחשבונכם כאשר מימשתם את המתנה.\n\nאם אתם חושבים שזו טעות, אנא צרו קשר עם צוות התמיכה שלנו וכללו כל פרט שיש לכם לגבי קוד המתנה ומתי מימשתם אותו.\n\nצוות {product_name}"
|
||||
"subject": "ההטבות מהמתנה שמימשתם הוסרו",
|
||||
"body": "שלום {username},\n\nקוד מתנה שמימשתם שולם במקור על ידי מישהו אחר. התשלום הזה בוטל מאז (החזר חיוב).\n\nבשל כך, הסרנו את ההטבות שנוספו לחשבונכם כאשר מימשתם את המתנה.\n\nאם אתם חושבים שזו טעות, אנא צרו קשר עם צוות התמיכה שלנו וצרפו כל פרט שידוע לכם לגבי קוד המתנה ומתי מימשתם אותו.\n\nצוות {product_name}"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "ייצוא הנתונים שלכם מ-{product_name} מוכן להורדה",
|
||||
"body": "שלום {username},\n\nייצוא הנתונים שלכם מוכן.\n\nקישור להורדה:\n{downloadUrl}\n\nהודעות כלולות: {totalMessages, number}\nגודל קובץ: {fileSizeMB, number} מגה-בייט\n\nקישור זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם ייצוא זה, אנא שנו את הסיסמה שלכם מיד וצרו קשר עם צוות התמיכה שלנו.\n\nצוות {product_name}"
|
||||
"body": "שלום {username},\n\nייצוא הנתונים שלכם מוכן.\n\nקישור להורדה:\n{downloadUrl}\n\nהודעות כלולות: {totalMessages, number}\nגודל הקובץ: {fileSizeMB, number} מגה-בייט\n\nקישור זה יפוג בתאריך {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם ייצוא זה, אנא שנו את הסיסמה שלכם מיד וצרו קשר עם צוות התמיכה שלנו.\n\nצוות {product_name}"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק עקב חוסר פעילות",
|
||||
"body": "שלום {username},\n\nלא ראינו פעילות בחשבון ה-{product_name} שלכם מאז {lastActiveDate, date, full}.\n\nאם לא תתחברו עד {deletionDate, date, full} בשעה {deletionDate, time, short}, חשבונכם יימחק לצמיתות עקב חוסר פעילות.\n\nהתחברו כאן:\n{loginUrl}\n\nאם השתמשתם ב-{product_name} לאחרונה, אנא צרו קשר עם צוות התמיכה שלנו מיד.\n\nצוות {product_name}"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "אישור כניסה מכתובת IP חדשה",
|
||||
"subject": "אישור התחברות מכתובת IP חדשה",
|
||||
"body": "שלום {username},\n\nזיהינו ניסיון התחברות לחשבון ה-{product_name} שלכם מכתובת IP חדשה:\n\nכתובת IP: {ipAddress}\nמיקום: {location}\n\nאם זה הייתם אתם, אנא אשרו כתובת IP זו על ידי לחיצה על הקישור למטה:\n\n{authUrl}\n\nאם לא ניסיתם להתחבר, אנא שנו את הסיסמה שלכם מיד.\n\nקישור זה תקף למשך 30 דקות.\n\nצוות {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "אישור גישה לקודי הגיבוי שלכם ב-{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}"
|
||||
"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_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}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד והפעילו אימות דו-שלבי.\n\nצוות {product_name}"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "איפוס הסיסמה שלכם ל-{product_name}",
|
||||
"body": "שלום {username},\n\nביקשתם איפוס סיסמה ל-{product_name}. השתמשו בקישור למטה כדי להגדיר סיסמה חדשה:\n\n{resetUrl}\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם בבטחה מדוא\"ל זה.\n\nקישור זה תקף למשך שעה אחת.\n\nצוות {product_name}"
|
||||
"subject": "איפוס הסיסמה שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nביקשתם איפוס סיסמה ל-{product_name}. השתמשו בקישור למטה כדי להגדיר סיסמה חדשה:\n\n{resetUrl}\n\nאם לא ביקשתם זאת, אתם יכולים להתעלם בבטחה מאימייל זה.\n\nקישור זה תקף למשך שעה אחת.\n\nצוות {product_name}"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "ההרשמה שלכם ל-{product_name} אושרה",
|
||||
"body": "שלום {username},\n\nחדשות טובות: ההרשמה שלכם ל-{product_name} אושרה.\n\nכעת אתם יכולים להתחבר לאפליקציית {product_name} כאן:\n{channelsUrl}\n\nברוכים הבאים לקהילת {product_name}.\n\nצוות {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "הדוח שלכם ב-{product_name} נבדק",
|
||||
"body": "שלום {username},\n\nהדוח שלכם (מזהה: {reportId}) נבדק על ידי צוות הבטיחות שלנו.{hasComment, select, yes {\n\nתגובה מצוות הבטיחות:\n{publicComment}} other {}}\n\nתודה שעזרתם לשמור על {product_name} בטוחה לכולם. אנו מתייחסים לכל הדיווחים ברצינות ומעריכים את תרומתכם לקהילה.\n\nאם יש לכם שאלות או חששות לגבי תוצאה זו, אנא צרו קשר עם {safety_email}.\n\nצוות הבטיחות של {product_name}"
|
||||
"subject": "הדיווח שלכם ב-{product_name} נבדק",
|
||||
"body": "שלום {username},\n\nהדיווח שלכם (מזהה: {reportId}) נבדק על ידי צוות הבטיחות שלנו.{hasComment, select, yes {\n\nתגובה מצוות הבטיחות:\n{publicComment}} other {}}\n\nתודה שעזרתם לשמור על {product_name} כמקום בטוח לכולם. אנו מתייחסים לכל הדיווחים ברצינות ומעריכים את תרומתכם לקהילה.\n\nאם יש לכם שאלות או חששות לגבי תוצאה זו, אנא צרו קשר עם {safety_email}.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק לצמיתות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם נקבע למחיקה לצמיתות.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nזוהי פעולת אכיפה חמורה. נתוני החשבון שלכם יימחקו לצמיתות בתאריך המתוכנן.\n\nאם אתם חושבים שהחלטת אכיפה זו שגויה, אתם יכולים להגיש ערעור. שלחו דוא\"ל לכתובת {appeals_email} מכתובת דוא\"ל זו.\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם נקבע למחיקה לצמיתות.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nזוהי פעולת אכיפה חמורה. נתוני החשבון שלכם יימחקו לצמיתות בתאריך המתוכנן.\n\nאם אתם חושבים שהחלטת אכיפה זו שגויה, אתם יכולים להגיש ערעור. שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "מחיקת חשבון ה-{product_name} שלכם מתוזמנת",
|
||||
"body": "שלום {username},\n\nביקשתם למחוק את חשבון ה-{product_name} שלכם. חשבונכם מתוזמן למחיקה לצמיתות ב:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}\n\nאם לא ביקשתם זאת, התחברו לחשבונכם כדי לבטל את המחיקה. אנו ממליצים גם לשנות את הסיסמה שלכם כדי לאבטח את החשבון.\n\nצוות {product_name}"
|
||||
"body": "שלום {username},\n\nביקשתם למחוק את חשבון ה-{product_name} שלכם. החשבון שלכם מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}\n\nאם לא ביקשתם זאת, התחברו לחשבונכם כדי לבטל את המחיקה. אנו ממליצים גם לשנות את הסיסמה שלכם כדי לאבטח את החשבון.\n\nצוות {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "השעיית חשבון ה-{product_name} שלכם הוסרה",
|
||||
"body": "שלום {username},\n\nחדשות טובות: השעיית חשבון ה-{product_name} שלכם הוסרה.\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nכעת אתם יכולים להתחבר שוב ולהמשיך להשתמש ב-{product_name} כרגיל.\n\nצוות הבטיחות של {product_name}"
|
||||
"subject": "השעיית חשבון ה-{product_name} שלכם בוטלה",
|
||||
"body": "שלום {username},\n\nחדשות טובות: השעיית חשבון ה-{product_name} שלכם בוטלה.\n\n{reason, select,\n null {}\n other {סיבה: {reason}}\n}\n\nכעת אתם יכולים להתחבר שוב ולהמשיך להשתמש ב-{product_name} כרגיל.\n\nצוות הבטיחות של {product_name}"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"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} सेफ्टी टीम"
|
||||
"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} सेफ्टी टीम"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "आपका {product_name} अकाउंट हमेशा के लिए डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस के उल्लंघन के कारण हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन शेड्यूल: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nयह एक गंभीर कदम है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\nकृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\nअपील प्रक्रिया:\nअगर आपको लगता है कि यह निर्णय गलत या अनुचित था, तो आपके पास अपील सबमिट करने के लिए 60 दिन हैं। इस ईमेल एड्रेस से {appeals_email} पर ईमेल करें।\n\nअपनी अपील में:\n- साफ तौर पर बताएं कि आपको क्यों लगता है कि यह निर्णय गलत या अनुचित था\n- कोई भी प्रासंगिक सबूत या संदर्भ दें\n\n{product_name} सेफ्टी टीम का एक मेंबर आपकी अपील का रिव्यू करेगा और अंतिम फैसला आने तक डिलीट होने की प्रक्रिया को रोक सकता है।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस के उल्लंघन के कारण हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन की निर्धारित तारीख: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nयह एक गंभीर कार्रवाई है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\nकृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\nअपील प्रक्रिया:\nअगर आपको लगता है कि यह फैसला गलत या अनुचित था, तो आपके पास अपील सबमिट करने के लिए 60 दिन हैं। इस ईमेल पते से {appeals_email} पर ईमेल करें।\n\nअपनी अपील में:\n- साफ़ तौर पर बताएं कि आपको क्यों लगता है कि यह फैसला गलत या अनुचित था\n- कोई भी प्रासंगिक सबूत या जानकारी दें\n\n{product_name} सेफ्टी टीम का एक सदस्य आपकी अपील का रिव्यू करेगा और अंतिम फैसला आने तक डिलीट होने की प्रक्रिया को रोक सकता है।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "आपका {product_name} अकाउंट अस्थायी रूप से सस्पेंड कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस का उल्लंघन करने के लिए अस्थायी रूप से सस्पेंड कर दिया गया है।\n\nअवधि: {durationHours, plural,\n =1 {1 घंटा}\n other {# घंटे}\n}\nसस्पेंड किया गया है: {bannedUntil, date, full} {bannedUntil, time, short} तक\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nइस दौरान, आप अपने अकाउंट को एक्सेस नहीं कर पाएंगे।\n\nकृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\nअगर आपको लगता है कि यह निर्णय गलत या अनुचित था, तो आप अपील सबमिट कर सकते हैं। इस ईमेल एड्रेस से {appeals_email} पर ईमेल करें और साफ तौर पर बताएं कि आपको क्यों लगता है कि फैसला गलत था। हम आपकी अपील का रिव्यू करेंगे और अपने फैसले के साथ जवाब देंगे।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस का उल्लंघन करने के लिए अस्थायी रूप से सस्पेंड कर दिया गया है।\n\nअवधि: {durationHours, plural,\n =1 {1 घंटा}\n other {# घंटे}\n}\nसस्पेंड रहेगा: {bannedUntil, date, full} {bannedUntil, time, short} तक\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nइस दौरान, आप अपने अकाउंट को एक्सेस नहीं कर पाएँगे।\n\nकृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\nअगर आपको लगता है कि यह फैसला गलत या अनुचित था, तो आप अपील सबमिट कर सकते हैं। इस ईमेल पते से {appeals_email} पर ईमेल करें और साफ़ तौर पर बताएं कि आपको क्यों लगता है कि फैसला गलत था। हम आपकी अपील का रिव्यू करेंगे और अपने फैसले के साथ जवाब देंगे।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन डिटेल्स:\nराशि: {amount} {currency} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें सभी पेमेंट डिटेल्स शामिल हैं और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक का उपयोग करके किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपनी सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपनी सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और भविष्य के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "अपने {product_name} डोनेशन मैनेज करें",
|
||||
"body": "नमस्ते,\n\nअपने डोनर पोर्टल एक्सेस करने के लिए नीचे दिए गए लिंक पर क्लिक करें:\n\n{manageUrl}\n\nपोर्टल में, आप सब्सक्रिप्शन मैनेज कर सकते हैं, इनवॉइस डाउनलोड कर सकते हैं और अपने डोनेशन का इतिहास देख सकते हैं।\n\nयह लिंक {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इस लिंक का अनुरोध नहीं किया था, तो आप इस ईमेल को अनदेखा कर सकते हैं।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते,\n\nअपना डोनर पोर्टल एक्सेस करने के लिए नीचे दिए गए लिंक पर क्लिक करें:\n\n{manageUrl}\n\nपोर्टल में, आप सब्सक्रिप्शन मैनेज कर सकते हैं, इनवॉइस डाउनलोड कर सकते हैं और अपने डोनेशन का इतिहास देख सकते हैं।\n\nयह लिंक {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इस लिंक का अनुरोध नहीं किया था, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\n– {product_name} टीम"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "DSA रिपोर्ट के लिए अपना ईमेल वेरिफाई करें",
|
||||
"body": "नमस्ते,\n\n{product_name} पर अपनी डिजिटल सर्विसेज एक्ट रिपोर्ट सबमिट करने के लिए नीचे दिए गए वेरिफिकेशन कोड का इस्तेमाल करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो आप इस ईमेल को अनदेखा कर सकते हैं।\n\n– {product_name} सेफ्टी टीम"
|
||||
"subject": "DSA रिपोर्ट के लिए अपना ईमेल वेरिफ़ाई करें",
|
||||
"body": "नमस्ते,\n\n{product_name} पर अपनी Digital Services Act रिपोर्ट सबमिट करने के लिए नीचे दिए गए वेरिफ़िकेशन कोड का इस्तेमाल करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसका अनुरोध नहीं किया था, तो आप इस ईमेल को अनदेखा कर सकते हैं।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"email_change_new": {
|
||||
"subject": "अपना नया {product_name} ईमेल वेरिफाई करें",
|
||||
"body": "नमस्ते {username},\n\nअपना नया {product_name} ईमेल वेरिफाई करने के लिए ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो आप इस ईमेल को अनदेखा कर सकते हैं।\n\n– {product_name} टीम"
|
||||
"subject": "अपना नया {product_name} ईमेल वेरिफ़ाई करें",
|
||||
"body": "नमस्ते {username},\n\nअपना नया {product_name} ईमेल वेरिफ़ाई करने के लिए ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसका अनुरोध नहीं किया था, तो आप इस ईमेल को अनदेखा कर सकते हैं।\n\n– {product_name} टीम"
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "अपने {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} टीम"
|
||||
"subject": "अपने {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} टीम"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "आपका {product_name} ईमेल बदल दिया गया था",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट पर ईमेल एड्रेस बदलकर {newEmail} कर दिया गया है।\n\nअगर आपने यह बदलाव किया है, तो किसी कार्रवाई की जरूरत नहीं है। अगर आपने नहीं किया है, तो आप इस लिंक का उपयोग करके बदलाव को वापस ले सकते हैं और अपना अकाउंट सुरक्षित कर सकते हैं:\n\n{revertUrl}\n\nयह आपके पिछले ईमेल को रीस्टोर करेगा, आपको हर जगह से साइन आउट करेगा, लिंक किए गए फोन नंबर हटाएगा, MFA को डिसेबल करेगा, और आपको एक नया पासवर्ड सेट करने की जरूरत होगी।\n\n– {product_name} सेफ्टी टीम"
|
||||
"subject": "आपका {product_name} ईमेल बदल दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट पर ईमेल पता बदलकर {newEmail} कर दिया गया है।\n\nअगर आपने यह बदलाव किया है, तो किसी कार्रवाई की ज़रूरत नहीं है। अगर आपने नहीं किया है, तो आप इस लिंक का उपयोग करके बदलाव को वापस ले सकते हैं और अपना अकाउंट सुरक्षित कर सकते हैं:\n\n{revertUrl}\n\nयह आपके पिछले ईमेल को रीस्टोर करेगा, आपको हर जगह से साइन आउट करेगा, लिंक किए गए फ़ोन नंबर हटाएगा, MFA को डिसेबल करेगा, और आपको एक नया पासवर्ड सेट करने की ज़रूरत होगी।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "अपना {product_name} ईमेल एड्रेस वेरिफाई करें",
|
||||
"body": "नमस्ते {username},\n\nकृपया नीचे दिए गए लिंक पर क्लिक करके अपने {product_name} अकाउंट के लिए ईमेल एड्रेस वेरिफाई करें:\n\n{verifyUrl}\n\nअगर आपने {product_name} अकाउंट नहीं बनाया है, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\nयह लिंक 24 घंटे के लिए वैलिड है।\n\n– {product_name} टीम"
|
||||
"subject": "अपना {product_name} ईमेल पता वेरिफ़ाई करें",
|
||||
"body": "नमस्ते {username},\n\nकृपया नीचे दिए गए लिंक पर क्लिक करके अपने {product_name} अकाउंट के लिए ईमेल पता वेरिफ़ाई करें:\n\n{verifyUrl}\n\nअगर आपने {product_name} अकाउंट नहीं बनाया है, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\nयह लिंक 24 घंटे तक मान्य है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "आपके रिडीम किए गए गिफ्ट से मिलने वाले पर्क हटा दिए गए हैं",
|
||||
"body": "नमस्ते {username},\n\nआपके द्वारा रिडीम किए गए एक गिफ्ट कोड का पेमेंट मूल रूप से किसी और ने किया था। वह पेमेंट तब से वापस ले लिया गया है (एक चार्जबैक)।\n\nइस वजह से, हमने वे पर्क हटा दिए हैं जो गिफ्ट रिडीम करते समय आपके अकाउंट में जोड़े गए थे।\n\nअगर आपको लगता है कि यह गलती है, तो कृपया हमारी सपोर्ट टीम से संपर्क करें और गिफ्ट कोड और उसे रिडीम करने की तारीख के बारे में जानकारी शेयर करें।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nआपने जो गिफ्ट कोड रिडीम किया था, उसका पेमेंट मूल रूप से किसी और ने किया था। उस पेमेंट को बाद में वापस ले लिया गया (चार्जबैक)।\n\nइस वजह से, हमने वे पर्क हटा दिए हैं जो गिफ्ट रिडीम करते समय आपके अकाउंट में जोड़े गए थे।\n\nअगर आपको लगता है कि यह गलती है, तो कृपया हमारी सपोर्ट टीम से संपर्क करें और गिफ्ट कोड और उसे रिडीम करने के समय के बारे में आपके पास जो भी जानकारी है, वह शेयर करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "आपका {product_name} डेटा एक्सपोर्ट डाउनलोड के लिए तैयार है",
|
||||
"body": "नमस्ते {username},\n\nआपका डेटा एक्सपोर्ट तैयार है।\n\nडाउनलोड लिंक:\n{downloadUrl}\n\nशामिल मैसेज: {totalMessages, number}\nफाइल साइज़: {fileSizeMB, number} MB\n\nयह लिंक {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इस एक्सपोर्ट की रिक्वेस्ट नहीं की थी, तो कृपया तुरंत अपना पासवर्ड बदलें और हमारी सपोर्ट टीम से संपर्क करें।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nआपका डेटा एक्सपोर्ट तैयार है।\n\nडाउनलोड लिंक:\n{downloadUrl}\n\nशामिल मैसेज: {totalMessages, number}\nफ़ाइल साइज़: {fileSizeMB, number} MB\n\nयह लिंक {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इस एक्सपोर्ट का अनुरोध नहीं किया था, तो कृपया तुरंत अपना पासवर्ड बदलें और हमारी सपोर्ट टीम से संपर्क करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "आपका {product_name} अकाउंट इनएक्टिविटी के कारण डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट पर {lastActiveDate, date, full} के बाद से कोई एक्टिविटी नहीं हुई है।\n\nअगर आप {deletionDate, date, full} को {deletionDate, time, short} तक लॉगिन नहीं करते हैं, तो आपका अकाउंट इनएक्टिविटी के कारण हमेशा के लिए डिलीट कर दिया जाएगा।\n\nयहां लॉगिन करें:\n{loginUrl}\n\nअगर आपने हाल ही में {product_name} का इस्तेमाल किया है, तो कृपया तुरंत हमारी सपोर्ट टीम से संपर्क करें।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट पर {lastActiveDate, date, full} के बाद से कोई एक्टिविटी नहीं हुई है।\n\nअगर आप {deletionDate, date, full} को {deletionDate, time, short} तक लॉगिन नहीं करते हैं, तो आपका अकाउंट इनएक्टिविटी के कारण हमेशा के लिए डिलीट कर दिया जाएगा।\n\nयहाँ लॉगिन करें:\n{loginUrl}\n\nअगर आपने हाल ही में {product_name} का इस्तेमाल किया है, तो कृपया तुरंत हमारी सपोर्ट टीम से संपर्क करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "एक नए IP एड्रेस से लॉगिन को ऑथराइज़ करें",
|
||||
"body": "नमस्ते {username},\n\nहमें एक नए IP एड्रेस से आपके {product_name} अकाउंट में लॉगिन करने का प्रयास किया गया है:\n\nIP एड्रेस: {ipAddress}\nलोकेशन: {location}\n\nअगर यह आपने ही किया था, तो कृपया नीचे दिए गए लिंक पर क्लिक करके इस IP एड्रेस को ऑथराइज़ करें:\n\n{authUrl}\n\nअगर आपने लॉगिन करने का प्रयास नहीं किया था, तो कृपया तुरंत अपना पासवर्ड बदलें।\n\nयह लिंक 30 मिनट के लिए वैलिड है।\n\n– {product_name} टीम"
|
||||
"subject": "नए IP एड्रेस से लॉगिन को ऑथराइज़ करें",
|
||||
"body": "नमस्ते {username},\n\nहमने आपके {product_name} अकाउंट में एक नए IP एड्रेस से लॉगिन का प्रयास देखा है:\n\nIP एड्रेस: {ipAddress}\nलोकेशन: {location}\n\nअगर यह आपने ही किया था, तो कृपया नीचे दिए गए लिंक पर क्लिक करके इस IP एड्रेस को ऑथराइज़ करें:\n\n{authUrl}\n\nअगर आपने लॉगिन करने का प्रयास नहीं किया था, तो कृपया तुरंत अपना पासवर्ड बदलें।\n\nयह लिंक 30 मिनट तक मान्य है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "अपने {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} टीम"
|
||||
"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_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} टीम"
|
||||
"subject": "अपने {product_name} पासवर्ड में बदलाव की पुष्टि करें",
|
||||
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट पर पासवर्ड बदलने का अनुरोध मिला है।\n\nइस बदलाव की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसका अनुरोध नहीं किया था, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें और टू-फ़ैक्टर ऑथेंटिकेशन इनेबल करें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "अपना {product_name} पासवर्ड रीसेट करें",
|
||||
"body": "नमस्ते {username},\n\nआपने {product_name} पासवर्ड रीसेट का अनुरोध किया है। नया पासवर्ड सेट करने के लिए नीचे दिए गए लिंक का उपयोग करें:\n\n{resetUrl}\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\nयह लिंक 1 घंटे के लिए वैलिड है।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nआपने {product_name} पासवर्ड रीसेट का अनुरोध किया है। नया पासवर्ड सेट करने के लिए नीचे दिए गए लिंक का उपयोग करें:\n\n{resetUrl}\n\nअगर आपने इसका अनुरोध नहीं किया था, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\nयह लिंक 1 घंटे तक मान्य है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"registration_approved": {
|
||||
"subject": "आपका {product_name} रजिस्ट्रेशन अप्रूव हो गया है",
|
||||
"body": "नमस्ते {username},\n\nअच्छी खबर: आपका {product_name} रजिस्ट्रेशन अप्रूव हो गया है।\n\nअब आप {product_name} ऐप में यहां लॉगिन कर सकते हैं:\n{channelsUrl}\n\n{product_name} कम्युनिटी में आपका स्वागत है।\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nअच्छी खबर: आपका {product_name} रजिस्ट्रेशन अप्रूव हो गया है।\n\nअब आप {product_name} ऐप में यहाँ लॉगिन कर सकते हैं:\n{channelsUrl}\n\n{product_name} कम्युनिटी में आपका स्वागत है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "आपकी {product_name} रिपोर्ट का रिव्यू कर लिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपकी रिपोर्ट (ID: {reportId}) का हमारी सेफ्टी टीम ने रिव्यू कर लिया है।{hasComment, select, yes {\n\nसेफ्टी टीम का जवाब:\n{publicComment}} other {}}\n\n{product_name} को सभी के लिए सुरक्षित रखने में मदद करने के लिए धन्यवाद। हम सभी रिपोर्टों को गंभीरता से लेते हैं और कम्युनिटी में आपके योगदान के लिए आभार व्यक्त करते हैं।\n\nअगर आपको इस परिणाम के बारे में कोई प्रश्न या चिंता है, तो कृपया {safety_email} पर संपर्क करें।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nआपकी रिपोर्ट (ID: {reportId}) का हमारी सेफ्टी टीम ने रिव्यू कर लिया है।{hasComment, select, yes {\n\nसेफ्टी टीम का जवाब:\n{publicComment}} other {}}\n\n{product_name} को सभी के लिए सुरक्षित रखने में मदद करने के लिए धन्यवाद। हम हर रिपोर्ट को गंभीरता से लेते हैं और कम्युनिटी में आपके योगदान की सराहना करते हैं।\n\nअगर आपको इस नतीजे के बारे में कोई सवाल या चिंता है, तो कृपया {safety_email} पर संपर्क करें।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "आपका {product_name} अकाउंट हमेशा के लिए डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन शेड्यूल: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nयह एक गंभीर कदम है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\nअगर आपको लगता है कि यह निर्णय गलत था, तो आप अपील सबमिट कर सकते हैं। इस ईमेल एड्रेस से {appeals_email} पर ईमेल करें।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन की निर्धारित तारीख: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}}\n}\n\nयह एक गंभीर कार्रवाई है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\nअगर आपको लगता है कि यह फैसला गलत था, तो आप अपील सबमिट कर सकते हैं। इस ईमेल पते से {appeals_email} पर ईमेल करें।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "आपके {product_name} अकाउंट डिलीशन को शेड्यूल किया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपने अपना {product_name} अकाउंट डिलीट करने का अनुरोध किया है। आपका अकाउंट हमेशा के लिए डिलीट करने के लिए इस तारीख को शेड्यूल किया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो डिलीशन रद्द करने के लिए अपने अकाउंट में साइन इन करें। हम आपके अकाउंट को सुरक्षित करने के लिए अपना पासवर्ड बदलने की भी सलाह देते हैं।\n\n– {product_name} टीम"
|
||||
"subject": "आपके {product_name} अकाउंट का डिलीशन शेड्यूल कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपने अपना {product_name} अकाउंट डिलीट करने का अनुरोध किया है। आपके अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर\n\nअगर आपने इसका अनुरोध नहीं किया था, तो डिलीशन रद्द करने के लिए अपने अकाउंट में साइन इन करें। हम आपके अकाउंट को सुरक्षित करने के लिए अपना पासवर्ड बदलने की भी सलाह देते हैं।\n\n– {product_name} टीम"
|
||||
},
|
||||
"unban_notification": {
|
||||
"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