Compare commits

...
Author SHA1 Message Date
HampusandGitHub 622bd124b9 fix(fonts): stop SC and TC from claiming kana (#2473) 2026-09-05 22:25:44 +02:00
HampusandGitHub fed8b2d089 feat(admin): add bulk delete user messages tool (#2472) 2026-09-05 22:20:14 +02:00
HampusandGitHub 12718eabbc refactor(api): drop cookie support for sudo mode (#2466) 2026-09-05 01:29:34 +02:00
HampusandGitHub ab68b61653 refactor: remove the CTP_MEMBER user flag (#2465) 2026-09-05 01:13:34 +02:00
HampusandGitHub 639ade3802 refactor(app-proxy): drop invite metadata and database access (#2464) 2026-09-05 00:13:04 +02:00
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
HampusandGitHub 18ae2e563e fix(worker): fit the job streams to the jetstream budget (#2458) 2026-09-04 19:31:20 +02:00
HampusandGitHub a4d039c910 fix(app-proxy): publish source maps with the asset tree (#2457) 2026-09-04 19:10:30 +02:00
HampusandGitHub 6163fd5644 fix(message): turn mentions red in failed messages (#2456) 2026-09-04 19:03:30 +02:00
HampusandGitHub 3e2ddaca4f fix(message): turn links red in failed messages (#2455) 2026-09-04 18:21:27 +02:00
HampusandGitHub 054a59e622 fix(message): keep reply previews on one line after a mention (#2454) 2026-09-04 18:20:14 +02:00
HampusandGitHub 84d7290ed9 fix(api): tighten guild emoji and sticker mutation limits (#2453) 2026-09-04 17:58:08 +02:00
HampusandGitHub f4c1254d91 fix(media-proxy): stop serving animated originals as stills (#2452) 2026-09-04 16:56:58 +02:00
HampusandGitHub c01d22dc05 fix(self-host): unfurl media hosted by the instance itself (#2451) 2026-09-04 16:56:35 +02:00
HampusandGitHub 4c0f02d8a5 chore(i18n): refresh catalogs for the window share audio scope (#2450) 2026-09-04 16:41:53 +02:00
HampusandGitHub 2770482baf perf(app-proxy): hold the frozen snapshot by reference (#2449) 2026-09-04 16:00:23 +02:00
HampusandGitHub 8e39a00e34 perf(app-proxy): run on jemalloc to curb arena growth (#2448) 2026-09-04 15:57:47 +02:00
HampusandGitHub 7bd0d3a962 fix(app-proxy): remove the SPA document render reservation (#2447) 2026-09-04 15:55:59 +02:00
HampusandGitHub bc7f701e87 feat(voice): give window shares their own audio scope (#2446) 2026-09-04 15:48:37 +02:00
HampusandGitHub 0d8116d73e fix(workspace): restore the devcontainer compose project name (#2445) 2026-09-04 15:41:38 +02:00
HampusandGitHub 255cbc1248 perf(app-proxy): drop dynamic brotli compression (#2444) 2026-09-04 14:47:49 +02:00
HampusandGitHub 098aeef412 fix(media-proxy): stop lifting bt709 video thumbnails (#2443) 2026-09-04 13:17:30 +02:00
HampusandGitHub baa18aed5b fix(media-proxy): link source-built native libs first (#2442) 2026-09-04 03:42:56 +02:00
HampusandGitHub b7c8dab019 fix(media-proxy): pin builder libheif, fix the image build (#2441) 2026-09-04 02:06:02 +02:00
HampusandGitHub 587324fa38 fix(voice): reuse the Linux audio capture across routing changes (#2440) 2026-09-04 01:00:32 +02:00
HampusandGitHub cc3a9c8613 fix(app): jitter gateway reconnects and recover status nagbar (#2439) 2026-09-03 23:21:32 +02:00
HampusandGitHub b0645300ec fix(i18n): reaction tooltip word order and plural agreement (#2438) 2026-09-03 22:11:59 +02:00
HampusandGitHub d7d4e8da03 refactor(media-proxy): split into modules and harden streaming (#2437) 2026-09-03 22:04:00 +02:00
HampusandGitHub 16ae98e189 fix(auth): regenerate backup codes with the emailed challenge (#2436) 2026-09-03 21:29:16 +02:00
HampusandGitHub add0a3dfc6 fix(voice): start bitrate for non-SVC screen share codecs (#2434) 2026-09-03 21:07:42 +02:00
HampusandGitHub 90c349392b fix(auth): email code to view backup codes, fix login matching (#2433) 2026-09-03 21:06:30 +02:00
HampusandGitHub eb4562b6a6 feat(voice): add screen share subscription debug helper (#2432) 2026-09-03 20:22:59 +02:00
HampusandGitHub 6c634b686f feat(voice): rework screen share audio source selection (#2431) 2026-09-03 20:01:28 +02:00
HampusandGitHub 48e03edccc chore(desktop): upgrade Electron to 44.1.1 (#2430) 2026-09-03 18:53:23 +02:00
HampusandGitHub cef6f11fd0 fix(app): correct the connection nagbar button styling (#2428) 2026-09-03 18:17:06 +02:00
HampusandGitHub 2757659989 fix(gateway): satisfy dialyzer after the hotpatch reconcile (#2427) 2026-09-03 17:26:51 +02:00
HampusandGitHub f090395c21 fix(voice): republish screen share when its codec goes stale (#2426) 2026-09-03 17:09:48 +02:00
HampusandGitHub dd1d554cc6 fix(gateway): reconcile hotpatched member-list and push fixes (#2425) 2026-09-03 17:04:32 +02:00
HampusandGitHub 9c1b38aeaf fix(api): always allow opening a dm channel (#2423) 2026-09-03 16:35:01 +02:00
HampusandGitHub 902dd60ff9 fix(voice): keep published codecs inside the opt-in policy (#2422) 2026-09-03 15:06:48 +02:00
HampusandGitHub 2426a5769d feat(voice): drive screen share quality from viewer demand (#2421) 2026-09-03 04:49:29 +02:00
HampusandGitHub 66517c925b feat(voice): show a passive badge when a stream underperforms (#2420) 2026-09-03 04:49:07 +02:00
HampusandGitHub 5f90e7d535 fix(api): downgrade oversized video instead of ending the call (#2419) 2026-09-03 04:47:02 +02:00
HampusandGitHub 9d63eb15a9 fix(voice): request a real camera frame rate at capture (#2418) 2026-09-03 04:46:32 +02:00
HampusandGitHub c97bc53342 refactor(voice): remove screen share codec renegotiation (#2417) 2026-09-03 04:46:11 +02:00
HampusandGitHub f5f60c66e7 refactor(voice): remove adaptive screen share quality system (#2416) 2026-09-03 04:41:04 +02:00
HampusandGitHub 3e15b97c8c fix(voice): stop clamping stored video quality preferences (#2415) 2026-09-03 04:36:04 +02:00
HampusandGitHub 6c08813f9b feat(voice): scale screen share bitrate to the selected rung (#2414) 2026-09-03 04:35:37 +02:00
HampusandGitHub 8158d44732 fix(voice): keep screen share resolution under constraint (#2413) 2026-09-03 04:35:12 +02:00
HampusandGitHub 9bd0019759 fix(api): declare undici for the bundled http client (#2410) 2026-09-02 19:55:11 +02:00
HampusandGitHub a74f1b0e7b fix(ci): clear knip, refresh openapi, close kv schema race (#2409) 2026-09-02 18:12:48 +02:00
HampusandGitHub 2b12f5db6c feat(voice): enable web camera background effects (#2408) 2026-09-02 17:40:13 +02:00
HampusandGitHub af4a52173c fix(voice): dispatch sourceLifecycle.removed on unbind (#2407) 2026-09-02 17:40:00 +02:00
HampusandGitHub 5bc1f21d46 fix(voice): drive call tiles from gateway voice state (#2406) 2026-09-02 17:39:48 +02:00
HampusandGitHub 917e437939 feat(voice-menus): add call controls to private call user menus (#2405) 2026-09-02 17:39:25 +02:00
HampusandGitHub 7ee4fb37d6 feat(voice): add a live input level meter to voice menus (#2404) 2026-09-02 17:39:01 +02:00
HampusandGitHub 6155eec804 feat(voice): flatten voice menus, gate ptt on a bound key (#2403) 2026-09-02 17:38:37 +02:00
HampusandGitHub 43d8637153 fix(voice): gate the camera preview and update effects in place (#2402) 2026-09-02 17:38:14 +02:00
HampusandGitHub 250db2fdab fix(voice): hide stream volume without remote share audio (#2401) 2026-09-02 17:37:52 +02:00
HampusandGitHub 7bd021cd5c feat(voice): open voice popouts in the browser (#2400) 2026-09-02 17:37:28 +02:00
HampusandGitHub adb9a689f0 feat(voice): share the voice room across popout trees (#2399) 2026-09-02 17:37:04 +02:00
HampusandGitHub d8e0c2ec20 fix(settings): stop auto-requesting devices, warn when none (#2398) 2026-09-02 17:36:49 +02:00
HampusandGitHub f98a74170a feat(settings): move macos permission review into desktop tab (#2397) 2026-09-02 17:36:26 +02:00
HampusandGitHub 17b9821879 fix(voice-menus): drive stream actions from the published source (#2396) 2026-09-02 17:36:05 +02:00
HampusandGitHub 4b5bdefcb9 fix(voice-menus): sentence-case participant menu labels (#2395) 2026-09-02 17:35:49 +02:00
HampusandGitHub 45cbabd94d fix(voice): abort screen share when its audio cannot start (#2394) 2026-09-02 17:35:26 +02:00
HampusandGitHub 8402eb53c8 feat(voice): skip the screen-share picker modal on web (#2393) 2026-09-02 17:35:15 +02:00
HampusandGitHub d04ace5789 feat(voice): redesign the screen-share source picker (#2392) 2026-09-02 17:35:01 +02:00
HampusandGitHub e94f587535 feat(voice): sequence join chimes ahead of entrance sounds (#2391) 2026-09-02 17:34:36 +02:00
HampusandGitHub e73285060e fix(voice): honour the codec preference in fallback selection (#2390) 2026-09-02 17:34:24 +02:00
HampusandGitHub f1734704ef fix(voice): guard stale screen-share negotiation and probes (#2389) 2026-09-02 17:34:10 +02:00
HampusandGitHub 59f6217267 refactor(voice): bound the screen-share codec wire formats (#2388) 2026-09-02 17:33:57 +02:00
HampusandGitHub 90f4a222b7 refactor(voice): request mic and camera permission separately (#2387) 2026-09-02 17:33:45 +02:00
HampusandGitHub 749d2091eb fix(voice): log local voice state hydration failures (#2386) 2026-09-02 17:33:32 +02:00
HampusandGitHub 8d34c6bcaa refactor(voice): make screen-share source swaps atomic (#2385) 2026-09-02 17:33:18 +02:00
HampusandGitHub 62577b25bb feat(voice): request web share audio via the browser picker (#2384) 2026-09-02 17:32:55 +02:00
HampusandGitHub 475f5a7dae fix(voice): refresh camera capture when the device changes (#2383) 2026-09-02 17:32:43 +02:00
HampusandGitHub 1772b3aad0 fix(voice): polish the stream settings menu (#2382) 2026-09-02 17:32:26 +02:00
HampusandGitHub 7263b21a06 refactor(voice): pin screen share to a fixed 7 Mbps bitrate (#2381) 2026-09-02 17:32:01 +02:00
HampusandGitHub 501adff13d refactor(voice): clamp premium video quality at read time (#2380) 2026-09-02 17:31:39 +02:00
HampusandGitHub 12c6fb7b7f feat(voice): add screen-share audio and rollback error handling (#2379) 2026-09-02 17:31:27 +02:00
HampusandGitHub 376168c922 feat(voice): add the camera background effects pipeline (#2378) 2026-09-02 17:31:04 +02:00
HampusandGitHub cadab239a2 feat(backgrounds): accept webm custom call backgrounds (#2377) 2026-09-02 17:30:50 +02:00
HampusandGitHub f57dc77c6d vendor(livekit): make local track swaps transactional (#2376) 2026-09-02 17:30:35 +02:00
HampusandGitHub 497a494c37 vendor(livekit): await setParameters in setDegradationPreference (#2375) 2026-09-02 17:30:22 +02:00
HampusandGitHub 02069e8e5d feat(voice-engine): add a sourceLifecycle.removed event (#2374) 2026-09-02 17:30:08 +02:00
HampusandGitHub 626293392c fix(ui): let callers style the audio level meter (#2373) 2026-09-02 17:29:55 +02:00
HampusandGitHub dfe42ae3e3 feat(sound): play one-shot sounds immediately and abortably (#2372) 2026-09-02 17:29:43 +02:00
HampusandGitHub 453abfa145 fix(ui): keep context menus clear of the native titlebar (#2371) 2026-09-02 17:29:29 +02:00
HampusandGitHub 8ebc9400ce fix(permissions): gate role hierarchy on known membership (#2370) 2026-09-02 17:29:06 +02:00
HampusandGitHub 1598f48edd fix(guild): invalidate member sidebar on role changes (#2369) 2026-09-02 17:28:44 +02:00
HampusandGitHub cc92f37f0c test(app): stop reading gl calls as react hooks (#2368) 2026-09-02 17:28:32 +02:00
HampusandGitHub 9322aca6cb fix(channel): restore composer draft and message focus (#2367) 2026-09-02 17:28:19 +02:00
HampusandGitHub ee8fbd6f4f fix(ui): keep the focus ring stable across refocus (#2366) 2026-09-02 17:27:57 +02:00
HampusandGitHub 1acd61a112 fix(ui): hand tooltips over between adjacent triggers (#2365) 2026-09-02 17:27:46 +02:00
HampusandGitHub e836686a71 fix(permissions): map not-determined media status to prompt (#2364) 2026-09-02 17:27:35 +02:00
HampusandGitHub ea6c417378 fix(discovery): keep category counts when a search resolves (#2363) 2026-09-02 17:27:22 +02:00
HampusandGitHub 16cc9a9e69 fix(app): clamp persisted accessibility values (#2362) 2026-09-02 17:27:10 +02:00
HampusandGitHub 6b5316fa84 fix(desktop): return a generic clipboard copy error (#2361) 2026-09-02 17:26:57 +02:00
HampusandGitHub a53f5d1289 fix(desktop): verify privileged ipc senders (#2360) 2026-09-02 17:26:45 +02:00
HampusandGitHub de2ea99928 fix(desktop): pin outbound fetches to a validated address (#2359) 2026-09-02 17:26:32 +02:00
HampusandGitHub 25f4332b9e fix(auth): guard stale submissions and rework form error mapping (#2358) 2026-09-02 17:26:18 +02:00
HampusandGitHub f703969e80 fix(auth): require a hashed poll secret for desktop handoff (#2357) 2026-09-02 17:25:54 +02:00
HampusandGitHub b82681b77a fix(auth): revoke the parsed token on logout (#2356) 2026-09-02 17:25:33 +02:00
HampusandGitHub ef248a8515 fix(platform): parse api error responses in one place (#2355) 2026-09-02 17:25:21 +02:00
HampusandGitHub 73a2345c26 fix(app-proxy): validate config, csp sources, cap resource use (#2354) 2026-09-02 17:25:10 +02:00
HampusandGitHub 9f33177eab fix(gateway): rate limit resume like identify (#2353) 2026-09-02 17:24:58 +02:00
HampusandGitHub d5a752c338 fix(gateway): only trust the client ip header when enabled (#2352) 2026-09-02 17:24:46 +02:00
HampusandGitHub 4021a2d697 fix(gateway): bound the zstd decompression window (#2351) 2026-09-02 17:24:35 +02:00
HampusandGitHub bea4a6dcbc fix(read-state): keep a failed ack dispatch from failing acks (#2350) 2026-09-02 17:24:24 +02:00
HampusandGitHub 4f48e04cad feat(api): serve well-known discovery with etag and 304 (#2349) 2026-09-02 17:24:11 +02:00
HampusandGitHub 5719dfe8a3 fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348) 2026-09-02 17:23:58 +02:00
HampusandGitHub 4fb14e85e8 fix(auth): harden login rate keys and totp reuse (#2347) 2026-09-02 17:23:47 +02:00
HampusandGitHub 1d84689b45 fix(api): validate push and domain verification targets (#2346) 2026-09-02 17:23:34 +02:00
HampusandGitHub 693aec2b4d fix(api): trust recorded upload types and bound edit sizes (#2345) 2026-09-02 17:23:23 +02:00
HampusandGitHub c79c0ee138 fix(api): bound storage listings, ranges and search paging (#2344) 2026-09-02 17:23:12 +02:00
HampusandGitHub e86e24a2db fix(captcha): drop the body-email contact policy exemption (#2343) 2026-09-02 17:23:02 +02:00
HampusandGitHub 0f7ad484ce fix(constants): add captcha, cache and feature headers (#2342) 2026-09-02 17:22:51 +02:00
HampusandGitHub bcd95b2af9 fix(http-client): validate public addresses at connect time (#2341) 2026-09-02 17:22:37 +02:00
HampusandGitHub 32dcd5ed1c chore(i18n): resync message catalogs with source (#2340) 2026-09-02 17:22:21 +02:00
HampusandGitHub 5119febb5b fix(api): send stickers through webhooks (#2338) 2026-09-02 13:55:54 +02:00
HampusandGitHub 20cdfd3009 fix(api): stop exporting unused worker heartbeat symbols (#2334) 2026-09-01 21:03:26 +02:00
HampusandGitHub 9f739427c4 fix(desktop): update rtrb past the double free advisory (#2336) 2026-09-01 21:03:19 +02:00
HampusandGitHub 0201cafd7e fix(admin): mark the crate unpublished so cargo deny passes (#2335) 2026-09-01 21:03:12 +02:00
HampusandGitHub 37f57bb29f fix(desktop): restore offline Flatpak builds (#2332) 2026-09-01 20:51:18 +02:00
HampusandGitHub ebd723679b docs(operator): refresh the bundle pin and tunnel setup (#2333) 2026-09-01 20:51:00 +02:00
HampusandGitHub 961fa1f007 fix(self-hosting): correct compose probes and origins (#2330) 2026-09-01 20:47:20 +02:00
HampusandGitHub 7900a4da0c feat(ci): pin releases to an immutable image set (#2327) 2026-09-01 20:47:20 +02:00
HampusandGitHub 8a24730884 fix(kv): align rust and typescript schema migration (#2328) 2026-09-01 20:47:19 +02:00
HampusandGitHub 1688e7dc50 fix(api): survive transient database errors in the worker (#2326) 2026-09-01 20:47:19 +02:00
HampusandGitHub aa267b54ec fix(api): dead-letter retired worker task types (#2323) 2026-09-01 20:47:19 +02:00
HampusandGitHub bc40073a02 fix(config): carry the public port into derived endpoints (#2329) 2026-09-01 20:47:18 +02:00
HampusandGitHub a93f9dd0af fix(app-proxy): separate readiness from liveness (#2322) 2026-09-01 20:47:18 +02:00
HampusandGitHub 53a9fdc4b6 fix(app-proxy): share one asset tree across architectures (#2325) 2026-09-01 20:47:17 +02:00
HampusandGitHub cef600277c fix(media-proxy): probe health with the binary not /dev/tcp (#2324) 2026-09-01 20:47:17 +02:00
HampusandGitHub bdac438329 fix(docker): emit consistent OCI metadata on every image (#2321) 2026-09-01 20:47:16 +02:00
HampusandGitHub 2d77f36a0b fix(ci): generate locale and channel files before typecheck (#2320) 2026-09-01 20:47:16 +02:00
HampusandGitHub 90aa810ce4 fix(gateway): share the relay dispatch bound across producers (#2315) 2026-09-01 04:34:41 +02:00
HampusandGitHub cf3af50464 fix(kv): bound multi key fan out by pipelining per hash slot (#2313) 2026-09-01 02:59:00 +02:00
HampusandGitHub 3b5b20c139 fix(gateway): bound relay dispatch without per-event probes (#2312) 2026-09-01 02:56:21 +02:00
HampusandGitHub 24cd163acd fix(kv): restore keyset paging for numeric key scans (#2314) 2026-09-01 02:54:10 +02:00
HampusandGitHub 49f76e5b40 fix(api): abort startup on unverifiable deletion queue state (#2311) 2026-09-01 02:45:29 +02:00
HampusandGitHub 871788f0a9 fix(gateway): reclaim ip connection counts from dead sockets (#2308) 2026-09-01 01:39:25 +02:00
HampusandGitHub 24138b70f1 fix(kv): stop multi-key commands spanning cluster slots (#2310) 2026-09-01 01:39:16 +02:00
HampusandGitHub da3332e711 fix(gateway): bound relay worker mailboxes without reordering (#2309) 2026-09-01 01:38:36 +02:00
HampusandGitHub 06e5cf2032 perf(gateway): evict presence tombstones in insertion order (#2307) 2026-09-01 01:34:57 +02:00
HampusandGitHub d4b1923c23 fix(gateway): stop presence evictions suppressing repair (#2305) 2026-09-01 01:33:13 +02:00
HampusandGitHub 42df4f6731 fix(kv): drop the row_key order probe that cliffed paged scans (#2306) 2026-09-01 01:32:33 +02:00
HampusandGitHub f1e6e94041 fix(cache): stop a timed out produce pinning its tracking entry (#2304) 2026-09-01 01:28:12 +02:00
HampusandGitHub 0cd12b2f32 test(api): build deletion queue users from the real row type (#2303) 2026-09-01 00:57:41 +02:00
HampusandGitHub 5da4d24d38 fix(kv): page scans by keyset so deletes cannot skip rows (#2302) 2026-09-01 00:29:24 +02:00
HampusandGitHub 7806d2ac02 fix(gateway): stop stale guild connect timers aborting connects (#2301) 2026-09-01 00:23:42 +02:00
HampusandGitHub 2c4d182d1f fix(gateway): keep dispatch ordered under relay backpressure (#2300) 2026-09-01 00:17:12 +02:00
HampusandGitHub dcd5f88d65 fix(gateway): stop rate limit tables dying with their creator (#2299) 2026-09-01 00:16:26 +02:00
HampusandGitHub 662f4ac93b fix(worker): stop skipped accounts starving the deletion queue (#2294) 2026-09-01 00:16:08 +02:00
HampusandGitHub a2480c6a02 fix(cache): time out a getOrSet produce that never settles (#2297) 2026-09-01 00:15:44 +02:00
HampusandGitHub c49460a44f fix(gateway): stop anti-entropy resurrecting deleted presence (#2298) 2026-09-01 00:14:32 +02:00
HampusandGitHub 6786dfe7e3 fix(gateway): stop dropping newly requested lazy ranges (#2293) 2026-09-01 00:14:24 +02:00
HampusandGitHub 7d710d881a fix(worker): lease premium reconciliation queue entries (#2296) 2026-09-01 00:14:08 +02:00
HampusandGitHub 2ea2e79f6f fix(voice): stop occupancy writes spanning kv cluster slots (#2295) 2026-09-01 00:11:29 +02:00
HampusandGitHub cd42dd8ca7 fix(api): rebuild the deletion queue under its lock (#2292) 2026-09-01 00:06:33 +02:00
HampusandGitHub f2eddeae4d fix(gateway): stop rate limit sweepers outliving their table (#2291) 2026-08-31 23:25:00 +02:00
HampusandGitHub 8e1a8fc7e3 fix(gateway): sweep stale shared ip and user rate buckets (#2290) 2026-08-31 22:53:37 +02:00
HampusandGitHub 87c08b051f fix(voice): finish the reconciliation sweep before stopping (#2289) 2026-08-31 22:38:04 +02:00
HampusandGitHub 9d95a80857 fix(worker): renew the deletion queue lock during a rebuild (#2287) 2026-08-31 22:38:00 +02:00
HampusandGitHub 9371b6d5de fix(worker): count each channel once in a bulk reindex (#2286) 2026-08-31 22:37:56 +02:00
HampusandGitHub bdcf4b25c0 chore(expressions): remove the pack residue cleanup tool (#2288) 2026-08-31 22:31:54 +02:00
HampusandGitHub 3dc344be65 fix(worker): keep attachment decay state on a stale expiry row (#2285) 2026-08-31 22:26:21 +02:00
HampusandGitHub 17ed0f70aa fix(gateway): release the user session count on a handoff fence (#2284) 2026-08-31 22:25:14 +02:00
HampusandGitHub be3e12e60d fix(gateway): clamp a heartbeat ack to the session sequence (#2283) 2026-08-31 22:23:43 +02:00
HampusandGitHub 4261cc2ea5 fix(worker): resubscribe when the job stream ends unexpectedly (#2282) 2026-08-31 22:22:14 +02:00
HampusandGitHub 88dbc27019 fix(gateway): group debounced reactions by their own message (#2281) 2026-08-31 22:21:14 +02:00
HampusandGitHub f38fc80c31 fix(gateway): clear the presence pid cache on a presence down (#2279) 2026-08-31 22:19:25 +02:00
HampusandGitHub 803fdaf443 fix(worker): stop replaying requeued asset deletions in a run (#2280) 2026-08-31 22:19:22 +02:00
HampusandGitHub 55d85db401 fix(gateway): drop the channel engine on an empty range list (#2278) 2026-08-31 22:17:19 +02:00
HampusandGitHub 7ce3d71c44 fix(gateway): stop a non-map opcode payload crashing the socket (#2277) 2026-08-31 22:14:26 +02:00
HampusandGitHub 04e150e4bf fix(gateway): keep the replay buffer across a session transfer (#2276) 2026-08-31 22:10:44 +02:00
HampusandGitHub 0f6b118921 fix(worker): catch up cron jobs missed by a delayed tick (#2275) 2026-08-31 22:06:15 +02:00
HampusandGitHub 44277e6aa2 fix(worker): drain in-flight jobs before the runner stops (#2274) 2026-08-31 22:06:12 +02:00
HampusandGitHub bb7e8cc6f1 fix(gateway): flush buffered presences in arrival order (#2273) 2026-08-31 22:04:49 +02:00
HampusandGitHub 32a64fb097 fix(cache): refcount produce tracking so deletes are not lost (#2272) 2026-08-31 22:00:48 +02:00
HampusandGitHub c4594397e7 fix(desktop): accept array-form AppRun sandbox fallback (#2271) 2026-08-31 21:42:03 +02:00
HampusandGitHub 6a188a4cdf fix(api): enforce guild bans when approving registrations (#2270) 2026-08-31 20:19:18 +02:00
HampusandGitHub 0ca0defd24 fix(desktop): keep notification sounds during fullscreen apps (#2269) 2026-08-31 19:51:42 +02:00
HampusandGitHub b0b84f9c98 fix(media-proxy): cap external streams with no declared length (#2267) 2026-08-31 19:23:48 +02:00
HampusandGitHub ef8d1225b5 refactor(api): split webhook attachment schemas (#2268) 2026-08-31 19:13:51 +02:00
HampusandGitHub 240b7e4388 feat(expressions): add an expression pack residue cleanup tool (#2265) 2026-08-31 19:06:31 +02:00
HampusandGitHub bd205d2250 fix(app-proxy): honour the shared Postgres settings (#2262) 2026-08-31 19:00:43 +02:00
HampusandGitHub e5e5bcccee docs(operator): pin self-hosting downloads to stable revision (#2266) 2026-08-31 18:57:58 +02:00
HampusandGitHub a5395b0109 fix(api): support presigned webhook attachments (#2264) 2026-08-31 18:54:25 +02:00
HampusandGitHub 09cea4394f fix(app-proxy): give each test fixture its own temp directory (#2261) 2026-08-31 18:49:30 +02:00
HampusandGitHub afeaddea22 fix(cache): do not fan a failed getOrSet out to its joiners (#2260) 2026-08-31 18:44:31 +02:00
HampusandGitHub 45f694310a fix(api): make the http header and request timeouts tunable (#2259) 2026-08-31 18:43:14 +02:00
HampusandGitHub 995f5118b2 fix(message): reap orphaned rows on the build paths (#2257) 2026-08-31 17:23:46 +02:00
HampusandGitHub 415888a615 fix(admin): stop a stale CSRF cookie wedging actions (#2258) 2026-08-31 17:23:35 +02:00
HampusandGitHub 542fb9176a fix(svc): allow disabling named Postgres prepared statements (#2256) 2026-08-31 17:19:44 +02:00
HampusandGitHub b8f8d8d859 feat(expressions): remove the unfinished packs feature (#2250) 2026-08-31 16:34:22 +02:00
HampusandGitHub 0eef611b6d test(message): pin response mapping across batch boundaries (#2255) 2026-08-31 16:24:33 +02:00
HampusandGitHub f0612ee860 fix(message): key batched message responses by message id (#2254) 2026-08-31 16:19:29 +02:00
HampusandGitHub 8c85cce75c fix(message): bound batched message response requests by size (#2252) 2026-08-31 16:13:36 +02:00
HampusandGitHub 5036ac3efa fix(api): allow disabling named Postgres prepared statements (#2251) 2026-08-31 16:10:52 +02:00
HampusandGitHub 9025e03422 feat(admin): remove the unfinished billing APIs and panel UI (#2248) 2026-08-31 15:57:11 +02:00
HampusandGitHub e82e8529bf fix(gateway): replay voice state updates after a resume (#2249) 2026-08-31 15:56:32 +02:00
HampusandGitHub eb1ed69489 chore(api): drop scheduled messages from the openapi spec (#2247) 2026-08-31 15:43:34 +02:00
HampusandGitHub e81f3f7eae fix(cache): do not resurrect a key deleted during getOrSet (#2246) 2026-08-31 15:41:05 +02:00
HampusandGitHub 4b9964bc89 fix(api): stop bounding request body receipt at the header timeout (#2245) 2026-08-31 15:36:54 +02:00
HampusandGitHub b4a2af75d0 fix(media-proxy): read content length from the response header (#2244) 2026-08-31 15:33:49 +02:00
HampusandGitHub 8c3e3285f7 fix(gateway): clamp the derived BEAM scheduler count (#2243) 2026-08-31 15:29:58 +02:00
HampusandGitHub 0a4f6ff9fb fix(test): surface docker errors when a test container fails (#2242) 2026-08-31 15:16:53 +02:00
HampusandGitHub bfa1367ca2 fix(gateway): restore erlfmt style in the presence rpc module (#2241) 2026-08-31 15:10:59 +02:00
HampusandGitHub bb81a2f165 fix(self-host): persist valkey and stop evicting durable state (#2240) 2026-08-31 14:48:58 +02:00
HampusandGitHub 7f448b1cab fix(message): always flag a message when a reaction is added (#2239) 2026-08-31 14:47:16 +02:00
HampusandGitHub 2dd35c0d6e fix(gateway): reject unknown rpc methods instead of crashing (#2238) 2026-08-31 14:34:35 +02:00
HampusandGitHub 0e73346c5f fix(svc): treat a shard overload reply as retryable backpressure (#2237) 2026-08-31 14:23:11 +02:00
HampusandGitHub 8476595507 fix(api): let node size its heap from the container limit (#2236) 2026-08-31 14:09:13 +02:00
HampusandGitHub 7b9284edb9 fix(build): allow unused patches when deploying the api subset (#2235) 2026-08-31 14:06:07 +02:00
HampusandGitHub c982b33212 fix(self-host): size memory limits and make them overridable (#2234) 2026-08-31 13:42:12 +02:00
HampusandGitHub 3c8466d714 feat(message): remove the unfinished scheduled messages feature (#2233) 2026-08-31 13:32:55 +02:00
HampusandGitHub eeea391b63 fix(kv): claim parked jobs by member instead of secondary key (#2232) 2026-08-31 13:16:52 +02:00
HampusandGitHub 5c2dca1c51 chore(workspace): tighten quality gates (#2230) 2026-08-31 04:43:20 +02:00
HampusandGitHub e6e4c6f7b5 perf(api): stop exempting self-hosted from response gating (#2228) 2026-08-31 02:04:07 +02:00
HampusandGitHub 5f6f9428ac build(api): bundle the api instead of transpiling at boot (#2227) 2026-08-31 01:26:30 +02:00
HampusandGitHub ba54b61dcf perf(guild): add a lean channel auth context rpc (#2226) 2026-08-31 01:04:55 +02:00
HampusandGitHub 8dc2bad843 perf(auth): cache auth session lookups by token hash (#2225) 2026-08-31 01:04:51 +02:00
HampusandGitHub 3594cbd5ca perf(svc): forward messages shard replies without transcoding (#2224) 2026-08-31 01:04:47 +02:00
HampusandGitHub 21b1e4e719 perf(ready): stop sending read state twice per session (#2223) 2026-08-31 01:04:43 +02:00
HampusandGitHub 50a17b6263 fix(metrics): reject non-loopback callers on metrics endpoints (#2222) 2026-08-31 00:23:00 +02:00
HampusandGitHub 0a920def2b fix(kv): mark the messages migration done instead of rescanning (#2221) 2026-08-31 00:22:56 +02:00
HampusandGitHub c4b1471923 perf(api): cache channel and guild reads for the request (#2220) 2026-08-31 00:22:52 +02:00
HampusandGitHub ab08ed0d7c chore(self-host): give every compose service a memory ceiling (#2212) 2026-08-31 00:22:48 +02:00
HampusandGitHub 34c13a747d chore(self-host): probe the api readiness during startup (#2216) 2026-08-31 00:17:09 +02:00
HampusandGitHub d559d8853d perf(gateway): size replay buffer entries once per dispatch (#2210) 2026-08-31 00:14:57 +02:00
HampusandGitHub a96d9cd075 perf(worker): skip the bunny purge cron when purging is off (#2208) 2026-08-31 00:14:39 +02:00
HampusandGitHub b163888cf3 perf(gateway): stop building discarded debug logs on fanout (#2219) 2026-08-31 00:13:27 +02:00
HampusandGitHub b07e2c397c perf(api): resolve the client ip once per request (#2218) 2026-08-31 00:13:24 +02:00
HampusandGitHub 3eeba1da2b fix(gateway): stop discarding container logs and add readiness (#2217) 2026-08-31 00:13:20 +02:00
HampusandGitHub e160b1bf07 perf(api): fast path json bodies with no large integers (#2214) 2026-08-31 00:13:17 +02:00
HampusandGitHub 1199b36d1a perf(worker): stop rereading rows in the discovery index sync (#2211) 2026-08-31 00:13:13 +02:00
HampusandGitHub 7a506478c7 perf(message): unlog bucket index writes on the read path (#2209) 2026-08-31 00:13:09 +02:00
HampusandGitHub 6faa40e0c2 fix(worker): bound the jobs stream and shed on overflow (#2204) 2026-08-31 00:13:04 +02:00
HampusandGitHub 768657d7e5 perf(worker): batch the inactivity sweep activity lookups (#2215) 2026-08-31 00:07:07 +02:00
HampusandGitHub 7157cca22f perf(worker): match the user export zip level to the guild one (#2213) 2026-08-31 00:07:03 +02:00
HampusandGitHub 7aec79d3ad perf(worker): throttle the cancel check in the domain sync (#2207) 2026-08-31 00:06:59 +02:00
HampusandGitHub 4357d5ec5d fix(search): stop leaking meilisearch task ids on the api (#2206) 2026-08-31 00:06:56 +02:00
HampusandGitHub 7c1c8b2749 perf(rate-limit): precompute bucket hash and client identifier (#2205) 2026-08-31 00:06:52 +02:00
HampusandGitHub 15656bd5c8 perf(users): read only the partial columns for partial requests (#2203) 2026-08-31 00:06:48 +02:00
HampusandGitHub c4897a7026 fix(svc): bound scylla request timeout below the rpc budget (#2202) 2026-08-31 00:06:44 +02:00
HampusandGitHub e993a47720 perf(guild): fetch guild members a thousand at a time (#2201) 2026-08-31 00:06:41 +02:00
HampusandGitHub 0d4c65ad79 perf(cassandra): skip re-registering identical select metadata (#2200) 2026-08-31 00:06:37 +02:00
HampusandGitHub f09bdb2b00 fix(cache): single-flight getOrSet and cache null results (#2199) 2026-08-31 00:06:33 +02:00
HampusandGitHub f1400ae58e fix(cassandra): shorten the read timeout below the rpc deadline (#2198) 2026-08-31 00:06:29 +02:00
HampusandGitHub b5496097d2 perf(message): reuse the resolved channel for dm send checks (#2197) 2026-08-31 00:06:25 +02:00
HampusandGitHub d5fb495e19 perf(read-state): read acked read states in one query (#2196) 2026-08-31 00:06:20 +02:00
HampusandGitHub 00e716bc3f perf(worker): skip the premium sweep on self-hosted instances (#2195) 2026-08-31 00:06:16 +02:00
HampusandGitHub ea4edd668f fix(worker): heartbeat long running jobs to hold the ack (#2194) 2026-08-31 00:06:12 +02:00
HampusandGitHub a22db125a9 perf(ready): send timing diagnostics only to staff sessions (#2190) 2026-08-30 23:50:09 +02:00
HampusandGitHub e7f68c2e20 test(message): count permission fetches instead of view checks (#2193) 2026-08-30 23:25:47 +02:00
HampusandGitHub 933b13f3fa perf(gateway): re-enable generational GC on hot processes (#2188) 2026-08-30 23:23:26 +02:00
HampusandGitHub 0be6c9c734 perf(gateway): skip permission cache rebuild on no-op updates (#2187) 2026-08-30 23:23:22 +02:00
HampusandGitHub 5aac331368 perf(gateway): skip materialising member list subscribers (#2184) 2026-08-30 23:23:18 +02:00
HampusandGitHub 57ec484626 fix(app): persist input access nagbar dismissal (#2192) 2026-08-30 23:20:51 +02:00
HampusandGitHub 9cd832bfa9 fix(app): let backspace cross a composer soft-wrap boundary (#2191) 2026-08-30 23:19:02 +02:00
HampusandGitHub 299cc40ff5 fix(gateway): split inbound and outbound rpc concurrency keys (#2186) 2026-08-30 23:16:53 +02:00
HampusandGitHub 6d305bacdf build(rust): enable fat lto and one codegen unit in release (#2185) 2026-08-30 23:16:49 +02:00
HampusandGitHub 6fd3177844 fix(auth): time-bound outbound fetches and re-key pwned cache (#2183) 2026-08-30 23:16:44 +02:00
HampusandGitHub 5586d34293 fix(app): preserve input access nagbar dismissal (#2189) 2026-08-30 23:11:57 +02:00
HampusandGitHub d43d242b16 perf(api): compile the phrase blocklist into one matcher (#2164) 2026-08-30 23:05:55 +02:00
HampusandGitHub 9f620e8c4b perf(user): prefetch user partials for list endpoints (#2182) 2026-08-30 23:05:17 +02:00
HampusandGitHub 6c9afcc734 perf(gateway): skip member list resync on inert presence deltas (#2180) 2026-08-30 23:05:13 +02:00
HampusandGitHub 43d6c85f7e perf(push): batch badge count invalidation per mention chunk (#2178) 2026-08-30 23:05:08 +02:00
HampusandGitHub 78056e0041 perf(gateway): pre-encode voice state update fanout (#2177) 2026-08-30 23:05:03 +02:00
HampusandGitHub e83a2d6aec perf(user): stop double-writing last active on every request (#2148) 2026-08-30 23:04:59 +02:00
HampusandGitHub bac06fe182 perf(gateway): restore generational GC as the vm default (#2181) 2026-08-30 23:01:25 +02:00
HampusandGitHub 8ff6518797 perf(postgres): name the fixed kv statement shapes (#2173) 2026-08-30 23:01:21 +02:00
HampusandGitHub f0e7c25e4c perf(kv): collate key columns in C and drop the duplicate index (#2162) 2026-08-30 23:01:06 +02:00
HampusandGitHub 0da94965dc perf(push): cache empty subscriptions and chunk fanout lookups (#2176) 2026-08-30 22:53:07 +02:00
HampusandGitHub d82eed16b7 perf(push): batch guild settings lookups for push eligibility (#2175) 2026-08-30 22:53:03 +02:00
HampusandGitHub b26748a2a7 fix(api): bound http server limits and shed on overload (#2170) 2026-08-30 22:53:00 +02:00
HampusandGitHub a2d7f5e8cc perf(app-proxy): serve precompressed assets and pass through (#2165) 2026-08-30 22:52:15 +02:00
HampusandGitHub 72ffa3bd9a perf(gateway): memoise the parsed internal rpc url (#2174) 2026-08-30 22:48:11 +02:00
HampusandGitHub e2fccaee74 fix(gateway): derive BEAM scheduler count from the environment (#2169) 2026-08-30 22:48:07 +02:00
HampusandGitHub e41b209cb8 perf(media-proxy): cache-probe and stream external fetches (#2168) 2026-08-30 22:48:03 +02:00
HampusandGitHub 2517caf674 fix(svc): shed overload instead of buffering router requests (#2171) 2026-08-30 22:45:50 +02:00
HampusandGitHub b9ec0d5f53 perf(gateway): avoid per-key exceptions in guild data wire (#2167) 2026-08-30 22:45:47 +02:00
HampusandGitHub 02e614632f perf(api): construct request services lazily (#2166) 2026-08-30 22:45:43 +02:00
HampusandGitHub 3ca73901c9 perf(gateway): cache zstd availability instead of reprobing it (#2160) 2026-08-30 22:45:38 +02:00
HampusandGitHub c379eed266 perf(gateway): split circuit breaker state from its window (#2154) 2026-08-30 22:45:34 +02:00
HampusandGitHub 5bac4fd719 perf(api): gate response schema revalidation to non-production (#2134) 2026-08-30 22:45:29 +02:00
HampusandGitHub dd610e4c0f fix(messages): pass message refs to the mention context helpers (#2179) 2026-08-30 22:44:12 +02:00
HampusandGitHub bf080cb001 fix(search): omit referenced message from search results (#2172) 2026-08-30 22:42:07 +02:00
HampusandGitHub 169088df26 perf(gateway): drop per-recipient mailbox probe on dispatch (#2146) 2026-08-30 22:42:03 +02:00
HampusandGitHub 4a2a29f154 perf(kv): merge row data in one statement on upsert and patch (#2161) 2026-08-30 22:37:18 +02:00
HampusandGitHub 1054962008 perf(kv): chunk oversized IN lists instead of scanning (#2163) 2026-08-30 22:36:51 +02:00
HampusandGitHub 8bc8603460 perf(message): skip redundant has_reaction writes on reactions (#2149) 2026-08-30 22:36:47 +02:00
HampusandGitHub 6538b0bfeb perf(message): skip reaction deletes for unreacted messages (#2147) 2026-08-30 22:36:44 +02:00
HampusandGitHub 9d2339bb3b perf(cassandra): memoize CQL statement metadata per query (#2155) 2026-08-30 22:34:49 +02:00
HampusandGitHub 096f38d365 perf(media-proxy): bound mime sniff scans and sniff once (#2159) 2026-08-30 22:29:00 +02:00
HampusandGitHub 1046edd903 perf(media-proxy): drop the extra HEAD on passthrough GETs (#2157) 2026-08-30 22:27:51 +02:00
HampusandGitHub cbf504dbb8 perf(api): memoise the effective bluesky oauth config (#2156) 2026-08-30 22:27:47 +02:00
HampusandGitHub 8491872908 perf(user): batch mention and saved message reads (#2139) 2026-08-30 22:27:41 +02:00
HampusandGitHub c207918e90 perf(api): fetch channel permissions in one gateway call (#2145) 2026-08-30 22:17:23 +02:00
HampusandGitHub 12717f692b chore(self-host): match shard admission to the postgres pool (#2153) 2026-08-30 22:17:12 +02:00
HampusandGitHub 36d630b37b perf(cassandra): drop allocations from the undefined param guard (#2152) 2026-08-30 22:16:26 +02:00
HampusandGitHub 5333fe7c3a perf(guild): unlog the audit log index batch (#2151) 2026-08-30 22:16:23 +02:00
HampusandGitHub efae78056e fix(worker): park far-future jobs in a KV due queue (#2144) 2026-08-30 22:16:18 +02:00
HampusandGitHub 6eca64a8f7 fix(user): stop invalidating user cache on profile reads (#2142) 2026-08-30 22:16:14 +02:00
HampusandGitHub fcb629ca06 perf(app-proxy): stream local assets instead of buffering them (#2141) 2026-08-30 22:16:10 +02:00
HampusandGitHub 289f1af253 perf(worker): skip guild settings for direct mentions (#2140) 2026-08-30 22:16:06 +02:00
HampusandGitHub 8adc3ecb0b perf(message): build pin responses in one messages round trip (#2138) 2026-08-30 22:16:02 +02:00
HampusandGitHub e328c001a1 perf(messages): prefilter mention extraction and drop a pass (#2137) 2026-08-30 22:15:58 +02:00
HampusandGitHub f796a31613 perf(worker): stop ledgering the two per-message tasks (#2136) 2026-08-30 22:15:54 +02:00
HampusandGitHub e1bab2e353 perf(message): reuse channel auth across send and edit (#2135) 2026-08-30 22:15:49 +02:00
HampusandGitHub 1c135176d2 perf(messages): stop channel history scan once the page is full (#2133) 2026-08-30 22:15:45 +02:00
HampusandGitHub 723f0d6e6e perf(logger): stop building a throwaway pino root per child (#2132) 2026-08-30 22:15:40 +02:00
HampusandGitHub e14d193b43 fix(api): order service timeouts so inner hops expire first (#2131) 2026-08-30 22:15:36 +02:00
HampusandGitHub 9d1733bdb3 perf(kv-client): use EVALSHA for rate limit scripts (#2130) 2026-08-30 22:15:32 +02:00
HampusandGitHub e06436d6f5 perf(svc): use cached prepared statements for postgres kv reads (#2129) 2026-08-30 22:15:28 +02:00
HampusandGitHub 4f67e2b362 perf(messages): overlap user partial fetch with response joins (#2128) 2026-08-30 22:15:24 +02:00
HampusandGitHub 8aa3415d73 perf(message): unlog bulk message delete batches (#2126) 2026-08-30 22:15:19 +02:00
HampusandGitHub 74f22e89ce perf(svc): reuse the decoded request instead of reparsing it (#2125) 2026-08-30 22:15:15 +02:00
HampusandGitHub 7d826d1602 perf(rpc): bound concurrency in user batch fanout handlers (#2124) 2026-08-30 22:15:11 +02:00
HampusandGitHub 8aa39bc7db perf(message): drop two reads and a write from message create (#2123) 2026-08-30 22:15:07 +02:00
HampusandGitHub 36dcf51024 fix(cassandra): bound driver in-flight requests per connection (#2122) 2026-08-30 22:15:02 +02:00
HampusandGitHub 3e74180bdc chore(self-host): budget postgres pool sizes across services (#2127) 2026-08-30 22:14:56 +02:00
HampusandGitHub 45ed740575 chore(self-host): tune bundled postgres for the shipped box (#2143) 2026-08-30 22:14:52 +02:00
HampusandGitHub 8092ad8c4d fix(media-proxy): support current FFmpeg APIs (#2158) 2026-08-30 22:08:55 +02:00
HampusandGitHub b4d9cdc584 refactor(voice): remove heartbeat and debug logging sessions (#2121) 2026-08-30 21:08:46 +02:00
HampusandGitHub 36b85512c6 chore(api): drop knip-unused Postgres KV exports (#2119) 2026-08-30 18:58:50 +02:00
HampusandGitHub 14ae64f5f3 perf(api): stop Postgres KV reads scanning whole tables (#2118) 2026-08-30 18:34:23 +02:00
M0N7Y5andGitHub 990176ac7c feat(markdown): add a binary AST envelope to the native ABI (#2117) 2026-08-30 17:47:58 +02:00
M0N7Y5andGitHub 69ef46356b feat(markdown): add a native C ABI for 64-bit FFI hosts (#2115) 2026-08-30 15:47:34 +02:00
HampusandGitHub bd88c7b04b fix(desktop): don't fail startup on inconclusive native probe (#2114) 2026-08-30 15:22:48 +02:00
HampusandGitHub 03641f622f refactor(voice): flatten participant context menu and extract stream menus (#2112) 2026-08-30 03:02:30 +02:00
HampusandGitHub 3b1eb56713 fix(voice): never auto-select AV1 or HEVC for screen sharing (#2111) 2026-08-30 02:05:21 +02:00
HampusandGitHub 059bcc6c53 chore(app): regenerate theme variable manifest for font fallbacks (#2110) 2026-08-30 01:30:00 +02:00
HampusandGitHub 82043ce2a8 fix(guild): show duplicated role in its final spot without flicker (#2109) 2026-08-30 01:25:34 +02:00
HampusandGitHub 470e752fba chore(i18n): refresh catalogs for role and permission menus (#2108) 2026-08-30 00:17:31 +02:00
HampusandGitHub 3cc7b9050c fix(app): clear stuck spellcheck reload banner (#2107) 2026-08-30 00:12:48 +02:00
HampusandGitHub b1f7c78c7e fix(app): overwrite context menu hover and delete danger item (#2106) 2026-08-30 00:12:41 +02:00
HampusandGitHub 8f20b29b16 feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105) 2026-08-30 00:12:36 +02:00
HampusandGitHub 19efbd3d61 fix(app): scope the show-send-button toggle to the main composer (#2104) 2026-08-30 00:12:31 +02:00
HampusandGitHub f35c0effa2 chore(i18n): drop unused gift redemption string (#2102) 2026-08-29 23:31:28 +02:00
HampusandGitHub 8363cc0844 fix(app): send only the gift link when gifting to a friend (#2101) 2026-08-29 23:29:49 +02:00
HampusandGitHub 5a11cacbae chore(i18n): refresh catalogs for spellcheck copy (#2100) 2026-08-29 23:03:01 +02:00
HampusandGitHub 27151a9487 fix(desktop): restore Linux spellcheck, prefer OS engine (#2099) 2026-08-29 22:58:48 +02:00
HampusandGitHub c152b25deb chore(i18n): refresh catalogs for global shortcut string (#2098) 2026-08-29 22:57:18 +02:00
HampusandGitHub 04d3afaf7b fix(app): default voice shortcuts to global and add a toggle (#2097) 2026-08-29 22:46:30 +02:00
HampusandGitHub dbc63e9ef7 fix(voice): surface stream audio volume for screen-share viewers (#2095) 2026-08-29 22:05:06 +02:00
HampusandGitHub ce91ff95ab fix(app): render raw unicode emoji with OS color emoji fonts (#2094) 2026-08-29 21:09:08 +02:00
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
2510 changed files with 188946 additions and 135514 deletions
+29 -1
View File
@@ -8,7 +8,7 @@ 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.122
ARG WASM_BINDGEN_VERSION=0.2.123
ENV DEBIAN_FRONTEND=noninteractive
@@ -32,6 +32,7 @@ RUN apt-get update \
jq \
libasound2 \
libatk-bridge2.0-0 \
libaom-dev \
libavcodec-dev \
libavfilter-dev \
libavformat-dev \
@@ -51,9 +52,15 @@ RUN apt-get update \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
libdav1d-dev \
libde265-dev \
liblcms2-dev \
libvips-dev \
libyuv-dev \
libwayland-dev \
libwebp-dev \
nasm \
yasm \
libssl-dev \
libx11-xcb1 \
libxcb-dri3-0 \
@@ -78,6 +85,7 @@ RUN apt-get update \
unzip \
xz-utils \
xdg-utils \
zlib1g-dev \
zstd \
&& rm -rf /var/lib/apt/lists/*
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
&& rm /tmp/fluxer-install-native-deps.sh
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
ENV LD_LIBRARY_PATH=/usr/local/lib
RUN printf '%s\n' \
'#include <libheif/heif.h>' \
'#include <string.h>' \
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
'#error the source-built libheif headers must win the include search' \
'#endif' \
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
>/tmp/fluxer-heif-probe.c \
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
&& /tmp/fluxer-heif-probe \
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
&& rm /tmp/fluxer-fonts-requirements.txt \
+2
View File
@@ -1,3 +1,5 @@
name: fluxer-dev
services:
workspace:
build:
+11 -4
View File
@@ -7,10 +7,16 @@ QUICK=0
SKIP_INSTALL=0
for arg in "$@"; do
case "$arg" in
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h | --help)
sed -n '2,25p' "$0"
exit 0
;;
*)
echo "unknown argument: $arg" >&2
exit 2
;;
esac
done
@@ -64,6 +70,7 @@ stage "app: typecheck" pnpm --filter fluxer_app typecheck
stage "app: unit tests" pnpm --filter fluxer_app exec vitest run
if [ "$QUICK" -eq 0 ]; then
stage "desktop: typecheck" pnpm --filter fluxer_desktop typecheck
stage "app: production build" pnpm --filter fluxer_app build
fi
+14 -14
View File
@@ -104,6 +104,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
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
with:
@@ -115,11 +118,13 @@ jobs:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
provenance: false
provenance: mode=min
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
${{ inputs.extra-build-args }}
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
@@ -185,17 +190,12 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
VERSION: ${{ needs.meta.outputs.build_version }}
MOVING_TAGS: ${{ inputs.moving-tags }}
run: |
set -euo pipefail
tag_args=()
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
for raw in "${moving[@]}"; do
tag="$(echo "$raw" | xargs)"
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
done
if (( ${#tag_args[@]} > 0 )); then
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
fi
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component "${{ inputs.image }}"
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags "${MOVING_TAGS}"
@@ -15,6 +15,13 @@ permissions:
contents: write
packages: write
concurrency:
group: publish-fluxer-app-proxy-self-hosted
cancel-in-progress: false
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve build release
@@ -26,13 +33,209 @@ jobs:
- name: approved
run: echo "Build release approved."
build:
meta:
name: resolve metadata
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
build-version: ${{ inputs['build-version'] }}
extra-build-args: |
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: set variables
id: vars
run: >-
tools/ci/run.sh build-app-proxy
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
dist:
name: build the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: write
env:
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
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
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build the dist once and publish it as the canonical asset image
env:
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_dist
- name: generate asset manifest
run: >-
tools/ci/run.sh build-app-proxy
--step generate_asset_manifest
- name: verify every manifest asset ships in the image
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build ${{ matrix.platform }}
needs: [meta, dist]
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
permissions:
actions: read
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
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
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
with:
context: .
file: fluxer_app_proxy/Dockerfile
push: true
provenance: false
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
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 }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
merge:
name: merge multi-arch manifest
needs: [meta, build]
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: create and push multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
VERSION: ${{ needs.meta.outputs.build_version }}
run: |
set -euo pipefail
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: >-
tools/ci/run.sh release
publish
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
- name: Advance moving image tags
env:
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+85 -16
View File
@@ -57,11 +57,11 @@ jobs:
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
build:
name: build app-proxy (amd64)
dist:
name: build and publish the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 60
permissions:
actions: read
contents: read
@@ -87,17 +87,17 @@ jobs:
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image + extract assets
- name: build the dist once and publish it as the canonical asset image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_and_extract
--step build_dist
- name: generate asset manifest
run: >-
@@ -114,9 +114,63 @@ jobs:
tools/ci/run.sh build-app-proxy
--step upload_assets
- name: verify every uploaded asset is readable
env:
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build app-proxy (amd64)
needs: [meta, dist]
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- 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"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_DATE: ${{ steps.source.outputs.date }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_image
build-arm64:
name: build app-proxy (arm64)
needs: meta
needs: [meta, dist]
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
permissions:
@@ -127,6 +181,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
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
with:
@@ -143,8 +200,10 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
BUNDLE_LOCAL_ASSETS=false
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
@@ -155,7 +214,7 @@ jobs:
name: merge multi-arch manifest
needs: [meta, build, build-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 20
permissions:
contents: write
packages: write
@@ -173,6 +232,15 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: fuse amd64 + arm64 into a multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
@@ -212,10 +280,11 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
docker buildx imagetools create
-t "${IMAGE}:v1"
-t "${IMAGE}:latest"
"${IMAGE}:${VERSION}"
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+142
View File
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: release image set
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
from-tag:
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
type: string
required: false
default: "v1"
component-versions:
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: read
concurrency:
group: release-image-set
cancel-in-progress: false
defaults:
run:
shell: bash
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve image set release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Image set release approved."
manifest:
name: resolve and publish the image set
needs: approve
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
permission-packages: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
tools/ci/run.sh resolve-calver
--github-output
- name: resolve release image set
id: resolve
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
FROM_TAG: ${{ inputs['from-tag'] }}
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
run: |
set -euo pipefail
args=(
image-set resolve
--version "${VERSION}"
--registry "ghcr.io/${GHCR_OWNER}"
--from-tag "${FROM_TAG}"
--out-dir release-out
--github-output
)
while IFS= read -r entry; do
entry="$(echo "$entry" | xargs)"
if [ -n "$entry" ]; then
args+=( --component-version "$entry" )
fi
done <<< "${COMPONENT_VERSIONS}"
tools/ci/run.sh "${args[@]}"
- name: verify release image set
env:
VERSION: ${{ steps.vars.outputs.build_version }}
run: >-
tools/ci/run.sh image-set verify
--manifest "release-out/fluxer-release-${VERSION}.json"
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
run: |
set -euo pipefail
if [ -z "${BUNDLE_COMMIT}" ]; then
echo "image-set resolve reported no bundle commit" >&2
exit 1
fi
gh release create "fluxer-release@${VERSION}" \
--repo fluxerapp/fluxer \
--target "${BUNDLE_COMMIT}" \
--title "fluxer-release ${VERSION}" \
--latest=true \
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
"release-out/fluxer-release-${VERSION}.json" \
"release-out/fluxer-release-${VERSION}.yml"
+72 -17
View File
@@ -152,8 +152,8 @@ jobs:
'packages/markdown_parser/rust/src/**') }}
rust:
runs-on: ubuntu-24.04
timeout-minutes: 30
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -174,24 +174,67 @@ jobs:
cache: 'pnpm'
- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
workspaces: |
. -> target
fluxer_desktop/native/rust -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
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
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Cache native media dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /opt/fluxer-native
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libcurl4-openssl-dev \
libvips-dev \
binutils \
clang \
cmake \
curl \
libaom-dev \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libdav1d-dev \
libde265-dev \
libfido2-dev \
libheif-dev \
libwebp-dev
liblcms2-dev \
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
libyuv-dev \
meson \
nasm \
ninja-build \
pkg-config \
xz-utils \
yasm \
zlib1g-dev
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -199,17 +242,29 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (desktop native)
run: cargo fmt --manifest-path fluxer_desktop/native/rust/Cargo.toml --all -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
- name: Clippy (warnings as errors)
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
run: |
set -euo pipefail
command -v ffmpeg
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
ffmpeg -hide_banner -version
- name: Run tests
run: cargo test --workspace
env:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run desktop native tests
run: cargo test --manifest-path fluxer_desktop/native/rust/Cargo.toml
- name: Run desktop native workspace tests on Linux
run: tools/ci/check-desktop-native-workspaces.sh test
gateway:
runs-on: ubuntu-24.04
Generated
+81 -370
View File
@@ -49,14 +49,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -127,9 +126,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arc-swap"
@@ -182,7 +181,7 @@ dependencies = [
"ring",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"serde",
"serde_json",
"serde_nanos",
@@ -190,7 +189,7 @@ dependencies = [
"thiserror",
"time",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"tokio-websockets",
@@ -528,23 +527,17 @@ dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
"h2 0.4.14",
"http 0.2.12",
"h2",
"http 1.4.2",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper 1.10.1",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower",
"tracing",
]
@@ -709,7 +702,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper",
"hyper-util",
"itoa",
"matchit",
@@ -933,9 +926,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1218,9 +1211,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1268,42 +1261,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -1652,7 +1622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1779,7 +1749,7 @@ dependencies = [
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper",
"hyper-util",
"image",
"libc",
@@ -1839,6 +1809,7 @@ dependencies = [
"clap",
"criterion",
"fluxer_common",
"futures-util",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1923,8 +1894,7 @@ dependencies = [
"libc",
"moka",
"rmp-serde",
"rustls 0.23.40",
"rustls-pemfile",
"rustls",
"scylla",
"serde",
"serde_json",
@@ -1989,6 +1959,7 @@ dependencies = [
"base64",
"chrono",
"cookie",
"fluxer_common",
"hmac 0.13.0",
"maud",
"openapiv3",
@@ -2019,16 +1990,14 @@ dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"fluxer_common",
"hex",
"moka",
"rand 0.10.1",
"reqwest",
"scylla",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
@@ -2100,16 +2069,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -2282,28 +2241,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.27"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2399,17 +2339,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -2417,7 +2346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -2496,30 +2425,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -2530,7 +2435,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2",
"http 1.4.2",
"http-body 1.0.1",
"httparse",
@@ -2542,21 +2447,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"log",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
@@ -2564,12 +2454,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"hyper-util",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower-service",
]
@@ -2585,12 +2475,12 @@ dependencies = [
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"hyper 1.10.1",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -2984,29 +2874,12 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -3014,19 +2887,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -3191,7 +3053,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3362,55 +3224,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -3420,20 +3252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -3442,22 +3261,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -3758,8 +3568,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
"socket2 0.6.3",
"rustls",
"socket2 0.5.10",
"thiserror",
"tokio",
"tracing",
@@ -3779,7 +3589,7 @@ dependencies = [
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
@@ -3797,7 +3607,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -4015,15 +3825,15 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@@ -4031,7 +3841,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4068,7 +3878,7 @@ dependencies = [
"futures",
"getrandom 0.2.17",
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"reqwest",
"reqwest-middleware",
"retry-policies",
@@ -4155,19 +3965,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki 0.101.7",
"sct",
"windows-sys 0.52.0",
]
[[package]]
@@ -4181,7 +3979,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"subtle",
"zeroize",
]
@@ -4198,15 +3996,6 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -4228,14 +4017,14 @@ dependencies = [
"jni",
"log",
"once_cell",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.13",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4244,16 +4033,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4346,23 +4125,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
]
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
"tendril",
]
[[package]]
@@ -4466,12 +4235,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -4765,9 +4534,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spki"
@@ -4785,19 +4554,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4806,30 +4562,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -4904,18 +4648,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
"windows-sys 0.52.0",
]
[[package]]
@@ -5086,7 +4819,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
"phf 0.13.1",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
@@ -5103,32 +4836,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb"
dependencies = [
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.4",
"tokio-rustls",
"x509-cert",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.40",
"rustls",
"tokio",
]
@@ -5172,7 +4895,7 @@ dependencies = [
"ring",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
]
@@ -5713,28 +5436,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
@@ -5805,7 +5516,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5
View File
@@ -25,3 +25,8 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "AGPL-3.0-or-later"
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
+1 -1
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
-1
View File
@@ -43,7 +43,6 @@ FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SVC_CACHE_TTL_MS=30000
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
+35 -14
View File
@@ -1,13 +1,9 @@
# cargo-deny configuration for the Fluxer workspace.
#
# Applies to the root workspace (Cargo.toml at the repo root) AND to every
# per-addon crate under fluxer_desktop/native/* (each addon has its own
# [workspace], so we invoke cargo-deny with --config pointing here).
#
# Used by the native desktop security gate in CI.
# Applies to the root workspace (Cargo.toml at the repo root).
[graph]
all-features = false
all-features = true
no-default-features = false
[output]
@@ -44,13 +40,11 @@ allow = [
"BSD-3-Clause",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"AGPL-3.0-or-later",
"BSL-1.0",
"OpenSSL",
"CDLA-Permissive-2.0",
]
# Explicitly deny GPL-only / strong-copyleft licenses that don't compose with
@@ -72,21 +66,48 @@ license-files = [
[bans]
multiple-versions = "warn"
wildcards = "deny"
# Per-addon crates path-depend on ../rust (the shared `fluxer_desktop_native`
# crate) without a version. cargo-deny flags that as a wildcard; we allow it
# because path deps can't realistically pin a SemVer range, and this only
# affects intra-repo workspace links (registry wildcards remain denied).
# Internal workspace crates use path dependencies without registry versions.
# Registry wildcards remain denied.
allow-wildcard-paths = true
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
# Keep desktop packaging and native addons away from the obsolete libfuse2 stack.
# Keep workspace artifacts away from the obsolete libfuse2 stack.
# AppImage packaging must use the static electron-builder runtime instead.
deny = [
{ crate = "fuse", reason = "libfuse2-based Rust wrapper; use a maintained FUSE3-native crate only if Fluxer ever needs FUSE directly" },
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
]
skip = []
skip = [
{ 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]", 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 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 HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI 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]", 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 digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older socket API" },
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
{ 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 = []
# ---------------------------------------------------------------------------
+113
View File
@@ -1,8 +1,41 @@
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_CADDY_SITE_ADDRESS at the same scheme and host.
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
# stay below the two values it reads. Above them it silently expands to a bare
# host with a trailing colon.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
#FLUXER_HTTP_PORT=19080
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
# the container, so change only these when something else already owns the
# standard ports or another proxy sits in front. Both take an optional bind
# address in front of the port, and 127.0.0.1 keeps the publish off every
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
# together, because HTTP/3 needs both on the same port.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#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. 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.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp
FLUXER_REGISTRY=ghcr.io/${FLUXER_REGISTRY_OWNER}
FLUXER_IMAGE_TAG=v1
@@ -30,6 +63,7 @@ [email protected]
#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
@@ -49,6 +83,20 @@ [email protected]
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# Ports LiveKit publishes on the host for voice and video media. They take the
# same optional bind address as the Caddy ports above. This media does not pass
# through Caddy or through a tunnel, so it needs these ports reachable from
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
# them on different host ports means changing that file too.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
# it only when LiveKit lives on its own host, and add that origin to
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
#FLUXER_LIVEKIT_URL=wss://voice.example.com
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
@@ -65,3 +113,68 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_DISCOVERY_ENABLED=true
# Container memory. Every service limit and reservation below has a default that
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
# their sum may exceed host RAM; the reservations are what protect the services
# whose death takes the whole instance down. Lower these on a smaller host.
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_API_MEMORY_LIMIT=2560mb
#FLUXER_API_MEMORY_RESERVATION=1gb
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
# 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.
#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.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB
# The bundled Valkey holds durable state as well as cache: the bulk message
# deletion queue, the account deletion queue and every distributed lock, none of
# which carry an expiry. 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. Only change the policy if you have moved that
# durable state 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.
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# 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.
#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.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
+295 -59
View File
@@ -1,6 +1,17 @@
name: fluxer
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
FLUXER_ENV: production
NODE_ENV: production
FLUXER_SELF_HOSTED: "true"
@@ -9,14 +20,8 @@ x-fluxer-env: &fluxer-env
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
@@ -50,6 +55,7 @@ x-fluxer-env: &fluxer-env
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
@@ -80,7 +86,7 @@ x-fluxer-env: &fluxer-env
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
@@ -90,36 +96,89 @@ x-fluxer-env: &fluxer-env
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
x-fluxer-service: &fluxer-service
restart: unless-stopped
networks: [fluxer]
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
services:
caddy:
image: caddy:2.10-alpine
deploy:
resources:
limits:
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "${FLUXER_HTTP_PORT:-80}:80"
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on: [api, gateway, media-proxy, static-proxy, admin]
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
timeout: 5s
retries: 10
depends_on:
api: {condition: service_started}
gateway: {condition: service_healthy}
media-proxy: {condition: service_started}
static-proxy: {condition: service_started}
admin: {condition: service_started}
postgres:
image: postgres:16-alpine
deploy:
resources:
limits:
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
reservations:
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
restart: unless-stopped
networks: [fluxer]
command: >
postgres
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
-c maintenance_work_mem=256MB
-c autovacuum_work_mem=128MB
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
-c jit=off
-c min_wal_size=512MB
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c wal_compression=zstd
-c bgwriter_delay=50ms
-c bgwriter_lru_maxpages=1000
-c autovacuum_vacuum_scale_factor=0.05
-c autovacuum_analyze_scale_factor=0.02
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -134,9 +193,17 @@ services:
valkey:
image: valkey/valkey:8.1-alpine
deploy:
resources:
limits:
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["valkey-server", "--save", "", "--appendonly", "no"]
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
@@ -145,35 +212,68 @@ services:
nats:
image: nats:2.14-alpine
deploy:
resources:
limits:
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 10s
timeout: 5s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
deploy:
resources:
limits:
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
restart: unless-stopped
networks: [fluxer]
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: 384mb
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
interval: 10s
timeout: 5s
retries: 20
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT:-128mb}
networks: [fluxer]
depends_on: [seaweedfs]
depends_on:
seaweedfs: {condition: service_healthy}
restart: "no"
entrypoint:
- /bin/sh
@@ -205,6 +305,10 @@ services:
livekit:
image: livekit/livekit-server:v1.12.0
deploy:
resources:
limits:
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["--config", "/etc/livekit.yaml"]
@@ -215,95 +319,152 @@ services:
ports:
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
timeout: 5s
retries: 10
api:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_API_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_API_MEMORY_RESERVATION:-1gb}
environment:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
meilisearch: {condition: service_started}
nats: {condition: service_healthy}
meilisearch: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
gifs: {condition: service_started}
gifs-shard: {condition: service_started}
snowflakes: {condition: service_started}
snowflakes-shard: {condition: service_started}
messages: {condition: service_started}
messages-shard: {condition: service_started}
users: {condition: service_started}
users-shard: {condition: service_started}
gifs: {condition: service_healthy}
gifs-shard: {condition: service_healthy}
snowflakes: {condition: service_healthy}
snowflakes-shard: {condition: service_healthy}
messages: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users: {condition: service_healthy}
users-shard: {condition: service_healthy}
worker:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["./node_modules/.bin/tsx", "src/WorkerEntrypoint.ts"]
command: ["node", "dist/WorkerEntrypoint.js"]
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
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)}"]
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
nats: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
snowflakes-shard: {condition: service_started}
messages-shard: {condition: service_started}
users-shard: {condition: service_started}
snowflakes-shard: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users-shard: {condition: service_healthy}
gateway:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gateway:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GATEWAY_MEMORY_LIMIT:-1gb}
reservations:
memory: ${FLUXER_GATEWAY_MEMORY_RESERVATION:-384mb}
environment:
<<: *fluxer-env
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_GATEWAY_LOGGER_LEVEL: info
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
valkey: {condition: service_healthy}
media-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-media-proxy:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
environment:
<<: *fluxer-env
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
healthcheck:
disable: true
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_started}
nats: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
interval: 10s
timeout: 5s
retries: 10
app-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-app-proxy-self-hosted:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
@@ -317,124 +478,198 @@ services:
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
depends_on:
api: {condition: service_healthy}
caddy: {condition: service_started}
postgres: {condition: service_healthy}
caddy: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: router
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
snowflakes-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
gifs:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
gifs-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
unfurl:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
unfurl-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
admin:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-admin:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_ADMIN_HOST: 0.0.0.0
FLUXER_ADMIN_PORT: "8080"
FLUXER_ADMIN_BASE_PATH: /admin
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin/oauth2_callback
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
api: {condition: service_healthy}
@@ -446,6 +681,7 @@ volumes:
caddy-data:
caddy-config:
postgres-data:
valkey-data:
nats-data:
meilisearch-data:
seaweedfs-data:
+4
View File
@@ -0,0 +1,4 @@
services:
caddy:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
+3 -1
View File
@@ -3,14 +3,16 @@ name = "fluxer_admin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
build = "build.rs"
[dependencies]
anyhow = "1.0.102"
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
+21 -1
View File
@@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY fluxer_common fluxer_common
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
@@ -31,12 +32,17 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
'members = ["fluxer_admin", "fluxer_common"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
'edition = "2024"' \
'license = "AGPL-3.0-or-later"' \
'' \
'[profile.release]' \
'lto = "fat"' \
'codegen-units = 1' \
'strip = "symbols"' \
> Cargo.toml
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
@@ -54,6 +60,20 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
FROM debian:bookworm-slim AS runtime
ARG BUILD_VERSION=""
ARG SOURCE_SHA=""
ARG SOURCE_DATE=""
LABEL org.opencontainers.image.title="fluxer-admin"
LABEL org.opencontainers.image.description="Fluxer admin console"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/local/bin
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -41,11 +41,9 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
pub const BILLING_REFUND: &str = "billing:refund";
pub const BILLING_VIEW: &str = "billing:view";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
@@ -155,11 +153,9 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_ADD,
BAN_PROFILE_SUBSTRING_CHECK,
BAN_PROFILE_SUBSTRING_REMOVE,
BILLING_MANAGE_SUBSCRIPTION,
BILLING_REFUND,
BILLING_VIEW,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
BULK_UPDATE_USER_FLAGS,
-5
View File
@@ -12,7 +12,6 @@ pub struct I32Flag {
pub mod user_flag_bits {
pub const STAFF: u64 = 1 << 0;
pub const CTP_MEMBER: u64 = 1 << 1;
pub const PARTNER: u64 = 1 << 2;
pub const BUG_HUNTER: u64 = 1 << 3;
pub const FRIENDLY_BOT: u64 = 1 << 4;
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "STAFF",
value: user_flag_bits::STAFF,
},
U64Flag {
name: "CTP_MEMBER",
value: user_flag_bits::CTP_MEMBER,
},
U64Flag {
name: "PARTNER",
value: user_flag_bits::PARTNER,
-154
View File
@@ -1,154 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
RefundCancelResponse, SubscriptionResponse,
};
impl AdminApiClient {
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
let response = self
.generated()
.admin_billing_overview(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
let response = self
.generated()
.admin_billing_list_payments(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
let response = self
.generated()
.admin_billing_get_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payment_methods(
&self,
user_id: &str,
) -> ApiResult<PaymentMethodListResponse> {
let response = self
.generated()
.admin_billing_list_payment_methods(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_invoices(
&self,
user_id: &str,
limit: u32,
starting_after: Option<&str>,
) -> ApiResult<InvoiceListResponse> {
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
if let Some(sa) = starting_after {
params.push(("starting_after", sa));
}
self.get(
&format!("/admin/billing/users/{user_id}/invoices"),
Some(&params),
)
.await
}
pub async fn issue_refund(
&self,
user_id: &str,
payment_intent_id: &str,
amount_cents: Option<u64>,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingRefundRequest {
amount_cents: amount_cents
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
payment_intent_id: payment_intent_id.to_owned(),
reason: reason
.map(generated_types::AdminBillingRefundRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_refund(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn refund_policy_cancel_now(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<RefundCancelResponse> {
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
reason: reason
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.admin_billing_refund_policy_cancel_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_cancel_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn cancel_subscription_immediately(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingCancelImmediatelyRequest {
reason: reason
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_cancel_subscription_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_reactivate_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_end_premium_grace_period(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+12
View File
@@ -72,6 +72,18 @@ impl AdminApiClient {
.await
}
pub async fn bulk_delete_user_messages(
&self,
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkDeleteUserMessagesRequest {
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/delete-user-messages", &body, audit_log_reason)
.await
}
pub async fn bulk_schedule_user_deletion(
&self,
user_ids: &[String],
-4
View File
@@ -32,10 +32,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
#[cfg(test)]
mod tests {
use super::{number_to_u64, types::*};
+1
View File
@@ -136,6 +136,7 @@ impl AdminApiClient {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
delete_message_seconds: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
-1
View File
@@ -8,7 +8,6 @@ pub mod archives;
pub mod assets;
pub mod audit;
pub mod bans;
pub mod billing;
pub mod bulk;
pub mod client;
pub mod codes;
-39
View File
@@ -1,39 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BillingOverview {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SubscriptionResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentMethodListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvoiceListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RefundCancelResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
-2
View File
@@ -4,7 +4,6 @@ mod admin_api_keys;
mod applications;
mod archives;
mod audit;
mod billing;
mod bulk;
mod codes;
mod common;
@@ -24,7 +23,6 @@ pub use admin_api_keys::*;
pub use applications::*;
pub use archives::*;
pub use audit::*;
pub use billing::*;
pub use bulk::*;
pub use codes::*;
pub use common::*;
+118 -20
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::normalize_public_endpoint_from_env;
use std::env;
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -42,14 +43,14 @@ pub enum RuntimeEnv {
impl AdminConfig {
pub fn from_env() -> Self {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = trim_trailing_slash(&read_env(
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
));
let oauth_redirect_uri = read_env_preferred(
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
&format!("{admin_endpoint}/oauth2_callback"),
);
));
Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
@@ -63,17 +64,19 @@ impl AdminConfig {
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
media_endpoint: trim_trailing_slash(&read_env(
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_MEDIA_ENDPOINT",
"https://media.fluxer.app",
))),
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
admin_endpoint,
web_app_endpoint: trim_trailing_slash(&read_env(
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
)),
))),
kv_url: read_env("FLUXER_KV_URL", ""),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
@@ -166,6 +169,39 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 11] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
let _guard = ENV_LOCK.lock().unwrap();
for name in MANAGED_ENV {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env();
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
@@ -287,18 +323,7 @@ mod tests {
#[test]
fn from_env_uses_defaults() {
for var in &[
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
] {
unsafe { env::remove_var(var) };
}
let config = AdminConfig::from_env();
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
@@ -308,4 +333,77 @@ mod tests {
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
assert_eq!(
config.oauth_redirect_uri,
format!("{}/oauth2_callback", config.admin_endpoint)
);
}
#[test]
fn a_default_public_port_leaves_the_public_endpoints_alone() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"https://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
assert_eq!(
config.oauth_redirect_uri,
"https://fluxer.example/admin/oauth2_callback"
);
}
#[test]
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
"http://fluxer.example/admin",
"fluxer.example",
Some(19080),
);
assert_eq!(
config.oauth_redirect_uri,
format!("{api_admin_endpoint}/oauth2_callback")
);
}
}
+17 -5
View File
@@ -86,25 +86,37 @@ pub async fn csrf_protection(
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
if is_production
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
{
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
let mut legacy = None;
for pair in cookie_header.split(';') {
let pair = pair.trim();
let value = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="));
if let Some(value) = value {
if let Some(value) = pair.strip_prefix("__Host-csrf_token=") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
} else if let Some(value) = pair.strip_prefix("csrf_token=")
&& legacy.is_none()
{
let trimmed = value.trim();
if !trimmed.is_empty() {
legacy = Some(trimmed.to_owned());
}
}
}
None
legacy
}
fn extract_csrf_header(request: &Request) -> Option<String> {
-16
View File
@@ -150,22 +150,6 @@ pub async fn render(
},
))
}
"billing" => {
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
return None;
}
let billing = client
.get_billing_overview(guild_id)
.await
.log_error("load guild billing overview")
.map(|b| b.data);
Some(tabs::billing::billing_tab(
config,
guild_id,
billing.as_ref(),
csrf_token,
))
}
"applications" => {
if !acl::has_any_permission(
admin_acls,
@@ -262,6 +262,12 @@ pub(crate) async fn bulk_actions_post(
)
.await
}
"bulk-delete-user-messages" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
client
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
.await
}
"bulk_delete_users" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
client
-49
View File
@@ -399,55 +399,6 @@ pub async fn dispatch(
"Bulk message deletion cancelled successfully",
"Failed to cancel bulk message deletion",
),
"refund_payment" => {
let Some(pi) = form.clean("payment_intent_id") else {
return DispatchOutcome::error("Payment intent ID is required");
};
let amt = form.parse_u64("amount_cents");
let reason = get("reason");
DispatchOutcome::from_result(
client
.issue_refund(user_id, &pi, amt, reason.as_deref())
.await,
"Refund issued successfully",
"Failed to issue refund",
)
}
"refund_policy_cancel_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.refund_policy_cancel_now(user_id, reason.as_deref())
.await,
"Refund policy cancellation completed successfully",
"Failed to apply refund policy cancellation",
)
}
"cancel_subscription" => DispatchOutcome::from_result(
client.cancel_subscription(user_id).await,
"Subscription cancelled successfully",
"Failed to cancel subscription",
),
"cancel_subscription_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.cancel_subscription_immediately(user_id, reason.as_deref())
.await,
"Subscription cancelled immediately",
"Failed to cancel subscription immediately",
)
}
"reactivate_subscription" => DispatchOutcome::from_result(
client.reactivate_subscription(user_id).await,
"Subscription reactivated successfully",
"Failed to reactivate subscription",
),
"end_premium_grace_period" => DispatchOutcome::from_result(
client.end_premium_grace_period(user_id).await,
"Premium grace period ended successfully",
"Failed to end premium grace period",
),
"message_shred" => {
let csv = form.first("csv_data").unwrap_or_default();
match parse_message_shred_csv(csv) {
-38
View File
@@ -155,44 +155,6 @@ pub async fn render(
csrf_token,
))
}
"billing" => {
if config.self_hosted
|| !acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
{
return None;
}
let can_view_billing = acl::has_permission(admin_acls, acl::BILLING_VIEW);
let b = if can_view_billing {
client
.get_billing_overview(user_id)
.await
.log_error("load user billing overview")
} else {
None
};
let invoices = if can_view_billing {
client
.get_user_invoices(user_id, 25, None)
.await
.log_error("load user invoices")
} else {
None
};
Some(tabs::billing::billing_tab(
config,
user_id,
b.as_ref().map(|v| &v.data),
invoices.as_ref().map(|v| &v.data),
csrf_token,
))
}
"guilds" => {
let g = client
.get_user_guilds(user_id, Some(200), None, None, Some(true))
@@ -30,12 +30,6 @@ pub fn user_profile_badges(
tooltip: "Fluxer Staff".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::CTP_MEMBER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/ctp.svg"),
tooltip: "Fluxer Community Team".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::PARTNER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/partner.svg"),
@@ -57,6 +57,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
]
),
],
@@ -51,10 +51,6 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "STAFF",
value: 1 << 0,
},
UserFlag {
name: "CTP_MEMBER",
value: 1 << 1,
},
UserFlag {
name: "PARTNER",
value: 1 << 2,
@@ -205,6 +201,9 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
}
}
};
admin_layout(config, auth, "Bulk Actions", "bulk-actions", None, content)
@@ -388,3 +387,24 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
section_card_simple(
"Bulk Delete User Messages",
html! {
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
(csrf_input(csrf_token))
div class="space-y-4" {
p class="text-neutral-500 text-sm" {
"Deletes every message authored by each user across all channels. This cannot be undone."
}
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! {
(danger_button("Delete All Messages"))
}))
}
}
},
)
}
@@ -1,94 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
pub fn billing_tab(
config: &AdminConfig,
guild_id: &str,
billing: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this guild."
}
}))
}
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=refresh_billing"}
class="block" {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Refresh Billing Data"))
}))
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
input type="text" name="reason" placeholder="Reason (optional)"
class="block w-full rounded-md border border-neutral-300 px-3 \
py-2 text-sm shadow-sm focus:border-brand-primary \
focus:outline-none focus:ring-1 focus:ring-brand-primary";
(form_actions(html! {
(danger_button("Cancel Subscription"))
}))
}
}
}
}))
}
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("none");
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription Status", html! {
span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs \
font-medium bg-neutral-100 text-neutral-700" {
(sub_status)
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
@@ -3,7 +3,6 @@
pub mod applications;
pub mod archives;
pub mod audit_log;
pub mod billing;
pub mod emojis;
pub mod features;
pub mod members;
@@ -22,7 +22,6 @@ use maud::{Markup, html};
pub const USER_TABS: &[(&str, &str)] = &[
("overview", "Overview"),
("account", "Account"),
("billing", "Billing"),
("guilds", "Guilds"),
("dm_history", "DM History"),
("group_dms", "Group DMs"),
@@ -164,21 +163,10 @@ fn render_user_detail(
}
}
fn user_tab_visible(config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
fn user_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
match tab_id {
"overview" | "account" | "guilds" | "dm_history" | "group_dms" | "reports"
| "moderation" => true,
"billing" => {
!config.self_hosted
&& acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
}
"relationships" => acl::has_permission(admin_acls, acl::USER_LIST_RELATIONSHIPS),
"applications" => acl::has_permission(admin_acls, acl::APPLICATION_LIST_BY_OWNER),
"archives" => acl::has_any_permission(
@@ -1,410 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
const INPUT_CLS: &str = "block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
focus:ring-brand-primary";
pub fn billing_tab(
config: &AdminConfig,
user_id: &str,
billing: Option<&serde_json::Value>,
invoices: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
(render_subscription(data))
(render_payment_methods(data))
(render_payments(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this user."
}
}))
}
@if let Some(data) = invoices {
(render_invoices(data))
}
(render_actions(base, user_id, csrf_token))
}
}
}
fn subscription_badge_variant(status: &str) -> BadgeVariant {
match status {
"active" | "trialing" => BadgeVariant::Success,
"past_due" | "unpaid" | "incomplete" => BadgeVariant::Warning,
"canceled" | "incomplete_expired" => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str());
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription", html! {
@if let Some(status) = sub_status {
(badge(status, subscription_badge_variant(status)))
} @else {
span class="text-sm text-neutral-900" { "none" }
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
fn render_subscription(data: &serde_json::Value) -> Markup {
let sub = match data.get("subscription") {
Some(s) if !s.is_null() => s,
_ => return html! {},
};
let status = sub
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let sub_id = sub.get("id").and_then(|v| v.as_str());
let plan_interval = sub.get("plan_interval").and_then(|v| v.as_str());
let period_start = sub.get("current_period_start").and_then(|v| v.as_str());
let period_end = sub.get("current_period_end").and_then(|v| v.as_str());
let cancel_at_period_end = sub
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Subscription", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Status", html! {
(badge(status, subscription_badge_variant(status)))
}))
@if let Some(id) = sub_id {
(detail_row("ID", html! {
span class="text-xs" { (id) }
}))
}
@if let Some(interval) = plan_interval {
(detail_row("Plan Interval", html! { (interval) }))
}
@if let Some(start) = period_start {
(detail_row("Period Start", html! { (start) }))
}
@if let Some(end) = period_end {
(detail_row("Period End", html! { (end) }))
}
(detail_row("Cancel at Period End", html! {
@if cancel_at_period_end { "yes" } @else { "no" }
}))
}
}))
}
}
fn render_payment_methods(data: &serde_json::Value) -> Markup {
let methods = data.get("payment_methods").and_then(|v| v.as_array());
let empty = methods.is_none() || methods.is_some_and(|m| m.is_empty());
html! {
(card_with_header("Payment Methods", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payment methods on file." }
} @else if let Some(pms) = methods {
div class="space-y-3" {
@for pm in pms {
@let pm_type = pm.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
@let brand = pm.get("card_brand").and_then(|v| v.as_str());
@let last4 = pm.get("card_last4").and_then(|v| v.as_str());
@let pm_id = pm.get("id").and_then(|v| v.as_str()).unwrap_or("");
@let display = match (brand, last4) {
(Some(b), Some(l)) => format!("{b} **** {l}"),
_ => pm_type.to_string(),
};
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3" {
p class="text-sm text-neutral-900" { (display) }
p class="text-xs text-neutral-500" { (pm_id) }
}
}
}
}
}))
}
}
fn render_payments(data: &serde_json::Value) -> Markup {
let payments = data.get("payments").and_then(|v| v.as_array());
let empty = payments.is_none() || payments.is_some_and(|p| p.is_empty());
html! {
(card_with_header("Payments", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payments recorded." }
} @else if let Some(ps) = payments {
div class="space-y-3" {
@for p in ps { (payment_row(p)) }
}
}
}))
}
}
fn payment_row(p: &serde_json::Value) -> Markup {
let amount = p.get("amount_cents").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = p.get("currency").and_then(|v| v.as_str()).unwrap_or("");
let status = p
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let created = p.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
let display_amount = format!("{:.2} {}", amount as f64 / 100.0, currency.to_uppercase());
let variant = match status {
"completed" | "succeeded" => BadgeVariant::Success,
"pending" | "processing" => BadgeVariant::Info,
"failed" | "canceled" => BadgeVariant::Danger,
"refunded" | "partially_refunded" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="flex items-center justify-between" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status, variant))
}
span class="text-xs text-neutral-500" { (created) }
}
}
}
}
fn invoice_badge_variant(status: Option<&str>) -> BadgeVariant {
match status {
Some("paid") => BadgeVariant::Success,
Some("open" | "draft") => BadgeVariant::Info,
Some("uncollectible" | "void") => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_invoices(data: &serde_json::Value) -> Markup {
let invoices = data.get("invoices").and_then(|v| v.as_array());
let empty = invoices.is_none() || invoices.is_some_and(|i| i.is_empty());
let has_more = data
.get("has_more")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Invoices", html! {
@if empty {
p class="text-sm text-neutral-500" { "No invoices on file." }
} @else if let Some(items) = invoices {
div class="space-y-3" {
@for invoice in items {
(invoice_row(invoice))
}
@if has_more {
p class="text-xs text-neutral-500" {
"More invoices exist beyond this list."
}
}
}
}
}))
}
}
fn invoice_row(invoice: &serde_json::Value) -> Markup {
let amount = invoice
.get("amount_paid")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let currency = invoice
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = invoice.get("status").and_then(|v| v.as_str());
let created = invoice
.get("created")
.and_then(|v| v.as_i64())
.map(format_unix_timestamp)
.unwrap_or_default();
let display_amount = format_amount(amount, currency);
let status_label = status.unwrap_or("unknown");
let billing_reason = invoice.get("billing_reason").and_then(|v| v.as_str());
let invoice_id = invoice.get("id").and_then(|v| v.as_str()).unwrap_or("");
let subscription_id = invoice.get("subscription_id").and_then(|v| v.as_str());
let payment_intent_id = invoice.get("payment_intent_id").and_then(|v| v.as_str());
let charge_id = invoice.get("charge_id").and_then(|v| v.as_str());
let hosted_invoice_url = invoice.get("hosted_invoice_url").and_then(|v| v.as_str());
let invoice_pdf = invoice.get("invoice_pdf").and_then(|v| v.as_str());
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="space-y-3" {
div class="flex items-center justify-between gap-3" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status_label, invoice_badge_variant(status)))
}
span class="text-xs text-neutral-500" { (created) }
}
@if let Some(reason) = billing_reason {
p class="text-sm text-neutral-500" { (reason) }
}
dl class="space-y-1" {
(compact_detail_row("id", invoice_id))
@if let Some(id) = subscription_id {
(compact_detail_row("subscription", id))
}
@if let Some(id) = payment_intent_id {
(compact_detail_row("payment_intent", id))
}
@if let Some(id) = charge_id {
(compact_detail_row("charge", id))
}
}
@if hosted_invoice_url.is_some() || invoice_pdf.is_some() {
div class="flex items-center gap-3 text-sm" {
@if let Some(url) = hosted_invoice_url {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"View"
}
}
@if let Some(url) = invoice_pdf {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"PDF"
}
}
}
}
}
}
}
}
fn compact_detail_row(label: &str, value: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-1 text-xs sm:grid-cols-3" {
dt class="text-neutral-500" { (label) }
dd class="break-all text-neutral-700 sm:col-span-2" { (value) }
}
}
}
fn format_amount(amount_minor: i64, currency: &str) -> String {
let code = currency.trim().to_uppercase();
if code.is_empty() {
format!("{:.2}", amount_minor as f64 / 100.0)
} else {
format!("{:.2} {code}", amount_minor as f64 / 100.0)
}
}
fn format_unix_timestamp(value: i64) -> String {
time::OffsetDateTime::from_unix_timestamp(value)
.ok()
.and_then(|ts| {
ts.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| value.to_string())
}
fn render_actions(base: &str, user_id: &str, csrf_token: &str) -> Markup {
html! {
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription_now"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel subscription immediately, no refund."
}
input type="text" name="reason" placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Cancel Now"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel at renewal (access until period end)."
}
(form_actions(html! {
(submit_button("Cancel at Renewal"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=refund_payment"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm font-medium text-neutral-700" {
"Manual Refund"
}
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
input type="text" name="payment_intent_id"
placeholder="pi_..." required
class=(INPUT_CLS);
input type="number" name="amount_cents" min="1"
placeholder="Amount cents (blank = full)"
class=(INPUT_CLS);
}
input type="text" name="reason"
placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Refund"))
}))
}
}
}
}))
}
}
@@ -5,7 +5,6 @@ use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
pub mod account;
pub mod applications;
pub mod archives;
pub mod billing;
pub mod dm_history;
pub mod group_dm;
pub mod guilds;
+267
View File
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
const ADMIN_ORIGIN: &str = "https://admin.example.test";
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
struct TestApp {
router: Router,
session_cookie: String,
}
#[tokio::test]
async fn clean_browser_can_submit_an_action() {
let app = setup().await;
let cookie = app.session_cookie.clone();
let (cookie_token, page_token) = load_page(&app, &cookie).await;
assert_eq!(cookie_token, page_token);
let with_csrf = format!("{cookie}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &with_csrf, &page_token).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn legacy_csrf_cookie_does_not_wedge_actions() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let (cookie_token, page_token) = load_page(&app, &stale).await;
assert_eq!(cookie_token, page_token);
let both = format!("{stale}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &both, &page_token).await;
assert_eq!(
status,
StatusCode::OK,
"a leftover unsigned csrf_token cookie must not block actions"
);
}
#[tokio::test]
async fn production_responses_expire_the_legacy_csrf_cookie() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let headers = page_headers(&app, &stale).await;
let expiry = headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find(|value| value.starts_with("csrf_token=;"))
.unwrap_or_else(|| panic!("legacy cookie was not expired: {headers:?}"));
assert!(expiry.contains("Max-Age=0"), "{expiry}");
assert!(expiry.contains("Path=/"), "{expiry}");
}
async fn setup() -> TestApp {
let api_endpoint = spawn_mock_api().await;
let router = build_router(production_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
}
}
fn production_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Production,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: ADMIN_ORIGIN.to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
async fn page_headers(app: &TestApp, cookie: &str) -> HeaderMap {
app.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap()
.headers()
.clone()
}
async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
let response = app
.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(status, StatusCode::OK, "{text}");
let cookie_token = host_csrf_cookie(&headers)
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
(cookie_token, page_token)
}
fn page_request(cookie: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri("/admin-api-keys")
.header(header::COOKIE, cookie)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap()
}
async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusCode {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/admin-api-keys?action=create")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.header(header::ORIGIN, ADMIN_ORIGIN)
.header("sec-fetch-site", "same-origin")
.header("HX-Request", "true")
.header("HX-Boosted", "true")
.header("HX-Target", "body")
.body(Body::from(format!(
"_csrf={form_token}&name=Legacy+Cookie+Key&acls=*"
)))
.unwrap(),
)
.await
.unwrap();
response.status()
}
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
value
.split(';')
.next()
.and_then(|pair| pair.trim().strip_prefix("__Host-csrf_token="))
.map(str::to_owned)
})
}
fn form_csrf_value(body: &str) -> Option<String> {
let marker = r#"name="_csrf" value=""#;
body.match_indices(marker)
.filter_map(|(index, _)| {
let rest = &body[index + marker.len()..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
})
.find(|value| !value.is_empty())
}
async fn spawn_mock_api() -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
(Method::POST, "/admin/api-keys") => Json(json!({
"key_id": "1900000000000000001",
"key": "fa_1900000000000000001_OneTimeSecretForTests",
"name": "Legacy Cookie Key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": null,
"acls": ["*"]
}))
.into_response(),
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
+19 -3
View File
@@ -23,11 +23,26 @@ COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm deploy --legacy --filter=fluxer_api --prod /out
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
FROM node:24-bookworm-slim
ARG BUILD_VERSION
ARG SOURCE_SHA
ARG SOURCE_DATE
LABEL org.opencontainers.image.title="fluxer-api"
LABEL org.opencontainers.image.description="Fluxer HTTP API and background workers"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/src/app/fluxer_api
@@ -48,6 +63,7 @@ RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/s
RUN corepack enable && corepack prepare [email protected] --activate
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 && \
@@ -57,7 +73,7 @@ RUN rm -rf pkgs && \
ENV HOME=/usr/src/app
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=2048"
ENV NODE_OPTIONS="--enable-source-maps"
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
ENV BUILD_VERSION=${BUILD_VERSION}
@@ -65,4 +81,4 @@ USER 65532:65532
EXPOSE 8080
CMD ["./node_modules/.bin/tsx", "src/AppEntrypoint.ts"]
CMD ["node", "dist/AppEntrypoint.js"]
+15 -2
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
@@ -17,6 +18,7 @@
"@bluesky-social/jwk-jose": "catalog:",
"@bluesky-social/oauth-client-node": "catalog:",
"@bufbuild/protobuf": "^2.12.0",
"@elastic/elasticsearch": "catalog:",
"@fluxer/config": "workspace:*",
"@fluxer/constants": "workspace:*",
"@fluxer/date_utils": "workspace:*",
@@ -29,6 +31,9 @@
"@fluxer/logger": "workspace:*",
"@fluxer/schema": "workspace:*",
"@fluxer/snowflake": "workspace:*",
"@hono/node-server": "catalog:",
"@messageformat/core": "catalog:",
"@messageformat/parser": "catalog:",
"@pkgs/cache": "workspace:*",
"@pkgs/captcha": "workspace:*",
"@pkgs/cassandra": "workspace:*",
@@ -49,35 +54,43 @@
"@pkgs/worker": "workspace:*",
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
"cassandra-driver": "catalog:",
"emoji-regex": "catalog:",
"fast-xml-parser": "catalog:",
"hi-base32": "catalog:",
"hono": "catalog:",
"html-entities": "catalog:",
"idna-uts46-hx": "catalog:",
"ioredis": "catalog:",
"itty-time": "catalog:",
"jose": "catalog:",
"livekit-server-sdk": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
"maxmind": "catalog:",
"mime": "catalog:",
"nats": "catalog:",
"nodemailer": "catalog:",
"pg": "catalog:",
"pino": "catalog:",
"sharp": "catalog:",
"stripe": "catalog:",
"tempy": "catalog:",
"transliteration": "catalog:",
"tsx": "catalog:",
"uint8array-extras": "catalog:",
"undici": "catalog:",
"validator": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "catalog:",
"@types/lodash": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
+5 -1
View File
@@ -7,6 +7,8 @@
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -14,6 +16,8 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+9 -3
View File
@@ -1,20 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CacheLogger} from '@pkgs/cache/src/CacheProviderTypes';
import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
try {
return JSON.parse(value);
return {hit: true, value: JSON.parse(value)};
} catch (error) {
if (logger) {
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
}
return null;
return {hit: false};
}
}
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
const parsed = parseCachedValue<T>(value, logger);
return parsed.hit ? parsed.value : null;
}
export function serializeValue<T>(value: T): string {
return JSON.stringify(value);
}
+160 -9
View File
@@ -1,17 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const CACHE_INFLIGHT_MAX_ENTRIES = 10000;
const CACHE_INFLIGHT_JOIN_RETRIES = 1;
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
interface CacheMSetEntry<T> {
key: string;
value: T;
ttlSeconds?: number;
}
interface CacheProduceTracking {
generation: number;
produces: number;
}
interface CacheProduceAbandonment {
abandoned: boolean;
}
export type CacheLookupResult<T> = {hit: true; value: T} | {hit: false};
type CacheTtlSeconds<T> = number | ((value: T) => number);
type CacheJoinResult<T> = {joined: true; value: T} | {joined: false; error: unknown};
export abstract class ICacheService {
abstract get<T>(key: string): Promise<T | null>;
private readonly inflightValues = new Map<string, Promise<unknown>>();
private readonly produceInvalidations = new Map<string, CacheProduceTracking>();
abstract getEntry<T>(key: string): Promise<CacheLookupResult<T>>;
abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
abstract delete(key: string): Promise<void>;
protected abstract deleteEntry(key: string): Promise<void>;
async delete(key: string): Promise<void> {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.generation += 1;
}
await this.deleteEntry(key);
}
abstract getAndDelete<T>(key: string): Promise<T | null>;
@@ -45,13 +76,133 @@ export abstract class ICacheService {
abstract sismember(key: string, member: string): Promise<boolean>;
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
const existingValue = await this.get<T>(key);
if (existingValue !== null) {
return existingValue;
async get<T>(key: string): Promise<T | null> {
const entry = await this.getEntry<T>(key);
return entry.hit ? entry.value : null;
}
async getOrSet<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds?: CacheTtlSeconds<T>,
produceTimeoutMs: number = CACHE_PRODUCE_TIMEOUT_MS,
): Promise<T> {
let generation = this.trackProduce(key);
try {
for (let attempt = 0; ; attempt++) {
const existing = await this.getEntry<T>(key);
if (existing.hit) {
return existing.value;
}
const inflight = this.inflightValues.get(key);
if (!inflight) {
return await this.produceSingleFlight(key, valueFactory, ttlSeconds, generation, produceTimeoutMs);
}
const joined = await this.joinInflight<T>(inflight);
if (joined.joined) {
return joined.value;
}
if (attempt >= CACHE_INFLIGHT_JOIN_RETRIES) {
throw joined.error;
}
generation = this.currentGeneration(key);
}
} finally {
this.releaseProduce(key);
}
const newValue = await valueFactory();
await this.set(key, newValue, ttlSeconds);
return newValue;
}
private trackProduce(key: string): number {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.produces += 1;
return tracked.generation;
}
this.produceInvalidations.set(key, {generation: 0, produces: 1});
return 0;
}
private currentGeneration(key: string): number {
return this.produceInvalidations.get(key)?.generation ?? 0;
}
private releaseProduce(key: string): void {
const tracked = this.produceInvalidations.get(key);
if (!tracked) {
return;
}
tracked.produces -= 1;
if (tracked.produces <= 0) {
this.produceInvalidations.delete(key);
}
}
private async joinInflight<T>(inflight: Promise<unknown>): Promise<CacheJoinResult<T>> {
try {
return {joined: true, value: (await inflight) as T};
} catch (error) {
return {joined: false, error};
}
}
private async produceSingleFlight<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
produceTimeoutMs: number,
): Promise<T> {
const abandonment: CacheProduceAbandonment = {abandoned: false};
const produced = this.boundProduce(
this.produceAndStore(key, valueFactory, ttlSeconds, generation, abandonment),
abandonment,
produceTimeoutMs,
);
if (this.inflightValues.size >= CACHE_INFLIGHT_MAX_ENTRIES) {
return await produced;
}
const pending = produced.finally(() => {
this.inflightValues.delete(key);
});
this.inflightValues.set(key, pending);
return await pending;
}
private boundProduce<T>(
produced: Promise<T>,
abandonment: CacheProduceAbandonment,
produceTimeoutMs: number,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
abandonment.abandoned = true;
reject(new Error(CACHE_PRODUCE_TIMEOUT_MESSAGE));
}, produceTimeoutMs);
timer.unref?.();
produced.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
},
);
});
}
private async produceAndStore<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
abandonment: CacheProduceAbandonment,
): Promise<T> {
const value = await valueFactory();
if (!abandonment.abandoned && this.currentGeneration(key) === generation) {
await this.set(key, value, typeof ttlSeconds === 'function' ? ttlSeconds(value) : ttlSeconds);
}
return value;
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
function createRecordingProvider(): {
client: IKVProvider;
commands: Array<Array<string>>;
} {
const commands: Array<Array<string>> = [];
const client = {
set: async (key: string) => {
commands.push([key]);
return 'OK';
},
setex: async (key: string) => {
commands.push([key]);
},
isClustered: () => true,
pipeline: () => {
const keys: Array<string> = [];
commands.push(keys);
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => [],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return {client, commands};
}
describe('KVCacheProvider cluster hash slots', () => {
it('keeps a multi entry write off batched commands that span hash slots', async () => {
const {client, commands} = createRecordingProvider();
const provider = new KVCacheProvider({client});
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
await provider.mset([
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
{key: 'cache:beta', value: 2},
]);
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
const MAX_CONCURRENT_ROUND_TRIPS = 16;
interface RecordingProvider {
client: IKVProvider;
batches: Array<Array<string>>;
peakInFlight: number;
}
function createRecordingProvider(clustered: boolean): RecordingProvider {
const recorder: RecordingProvider = {
client: {} as IKVProvider,
batches: [],
peakInFlight: 0,
};
let inFlight = 0;
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
recorder.batches.push(keys);
inFlight += 1;
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
};
recorder.client = {
isClustered: () => clustered,
set: async (key: string) => {
await trackRoundTrip([key]);
return 'OK';
},
setex: async (key: string) => {
await trackRoundTrip([key]);
},
pipeline: () => {
const keys: Array<string> = [];
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => {
await trackRoundTrip(keys);
return [];
},
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return recorder;
}
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
return Array.from({length: count}, (_unused, index) => ({
key: `cache:entry:${index}`,
value: index,
ttlSeconds: 60,
}));
}
describe('KVCacheProvider multi entry write fan out', () => {
it('writes every entry in one round trip outside cluster mode', async () => {
const recorder = createRecordingProvider(false);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
expect(recorder.peakInFlight).toBe(1);
});
it('surfaces a failed command inside a batched write', async () => {
const client = {
isClustered: () => false,
pipeline: () => {
const batch = {
set: () => batch,
setex: () => batch,
exec: async () => [[new Error('write rejected'), null]],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
const provider = new KVCacheProvider({client});
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
});
it('bounds concurrent round trips when entries span hash slots', async () => {
const recorder = createRecordingProvider(true);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
expect(recorder.batches.flat().length).toBe(1000);
});
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {describe, expect, it, vi} from 'vitest';
function createKVCacheProvider(): {
provider: KVCacheProvider;
store: Map<string, string>;
ttls: Array<[string, number]>;
} {
const store = new Map<string, string>();
const ttls: Array<[string, number]> = [];
const client = {
get: async (key: string) => store.get(key) ?? null,
set: async (key: string, value: string) => {
store.set(key, value);
return 'OK';
},
setex: async (key: string, ttlSeconds: number, value: string) => {
ttls.push([key, ttlSeconds]);
store.set(key, value);
},
} as unknown as IKVProvider;
return {provider: new KVCacheProvider({client}), store, ttls};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('ICacheService.getOrSet', () => {
it('runs the factory once for concurrent callers on the same key', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 7;
});
const results = await Promise.all([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(results).toEqual([7, 7, 7]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(cache.get('key')).resolves.toBe(7);
});
it('does not coalesce concurrent callers on different keys', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 1;
});
await Promise.all([cache.getOrSet('a', factory), cache.getOrSet('b', factory)]);
expect(factory).toHaveBeenCalledTimes(2);
});
it('caches a null factory result and serves it as a hit', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => null);
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('serves a stored json null from the kv provider as a hit', async () => {
const {provider, store} = createKVCacheProvider();
const factory = vi.fn(async () => null);
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(store.get('key')).toBe('null');
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('treats an unparseable stored value as a miss', async () => {
const {provider, store} = createKVCacheProvider();
store.set('key', '{not json');
const factory = vi.fn(async () => 3);
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
expect(factory).toHaveBeenCalledTimes(1);
});
it('resolves the ttl from the produced value', async () => {
const {provider, store, ttls} = createKVCacheProvider();
const resolver = (value: number | null) => (value === null ? 5 : 30);
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
expect(ttls).toEqual([
['present', 30],
['absent', 5],
]);
expect(store.get('absent')).toBe('null');
});
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
const cache = new InMemoryProvider();
const failing = vi.fn(async () => {
await delay(10);
throw new Error('factory failed');
});
const settled = await Promise.allSettled([
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected', 'rejected', 'rejected']);
expect(failing).toHaveBeenCalledTimes(2);
await expect(cache.exists('key')).resolves.toBe(false);
const succeeding = vi.fn(async () => 11);
await expect(cache.getOrSet('key', succeeding)).resolves.toBe(11);
expect(succeeding).toHaveBeenCalledTimes(1);
});
it('does not fan a transient producer failure out to the callers that joined it', async () => {
const cache = new InMemoryProvider();
let calls = 0;
const factory = vi.fn(async () => {
calls += 1;
const attempt = calls;
await delay(10);
if (attempt === 1) {
throw new Error('transient failure');
}
return 11;
});
const settled = await Promise.allSettled([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
expect(settled.filter((result) => result.status === 'fulfilled').map((result) => result.value)).toEqual([
11, 11, 11,
]);
expect(factory).toHaveBeenCalledTimes(2);
await expect(cache.get('key')).resolves.toBe(11);
});
});
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {describe, expect, it, vi} from 'vitest';
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
const PRODUCE_TIMEOUT_MS = 50;
const PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void} {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned' | 'rejected'> {
return Promise.race([
pending.then(
(value) => value,
() => 'rejected' as const,
),
new Promise<'pinned'>((resolve) => setTimeout(() => resolve('pinned'), ms)),
]);
}
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
}
describe('cache invalidation during an in-flight produce', () => {
it('does not resurrect a value deleted while the factory was running', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await cache.delete('session');
gate.resolve('revoked-session');
await expect(pending).resolves.toBe('revoked-session');
expect(await cache.get('session')).toBeNull();
});
it('still stores the value when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
gate.resolve('live-session');
await pending;
expect(await cache.get('session')).toBe('live-session');
});
it('does not resurrect a value deleted while a retried produce was running', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
await cache.delete('session');
gates[1].resolve('fresh-after-delete');
await expect(joiner).resolves.toBe('fresh-after-delete');
expect(await cache.get('session')).toBeNull();
});
it('stores the value a retried produce built when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('stores the value a retried produce built after the first produce was invalidated', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
await cache.delete('session');
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await flush();
await cache.set('session', 'served-from-cache', 30);
await expect(cache.getOrSet('session', async () => 'unused', 30)).resolves.toBe('served-from-cache');
await cache.delete('session');
gate.resolve('stale-produce');
await expect(pending).resolves.toBe('stale-produce');
expect(await cache.get('session')).toBeNull();
});
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const first = deferred<string>();
const second = deferred<string>();
const firstProduce = cache.getOrSet('session', async () => await first.promise, 30);
const secondProduce = cache.getOrSet('session', async () => await second.promise, 30);
await flush();
first.resolve('first-produce');
await expect(firstProduce).resolves.toBe('first-produce');
await cache.delete('session');
second.resolve('second-produce');
await expect(secondProduce).resolves.toBe('second-produce');
expect(await cache.get('session')).toBeNull();
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('drops produce tracking once the last produce for a key settles', async () => {
const cache = new InMemoryProvider();
for (let index = 0; index < 50; index++) {
const gate = deferred<string>();
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
await cache.delete(`session:${index}`);
gate.resolve('value');
await pending;
}
const shared = deferred<string>();
const producer = cache.getOrSet('shared', async () => await shared.promise, 30);
const joiner = cache.getOrSet('shared', async () => 'unused', 30);
await flush();
await cache.delete('shared');
shared.resolve('shared-value');
await Promise.all([producer, joiner]);
const failing = cache.getOrSet(
'failing',
async () => {
throw new Error('produce failed');
},
30,
);
await expect(failing).rejects.toThrow('produce failed');
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('does not pin a key forever when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
await expect(pinned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
const recovered = cache.getOrSet('session', async () => 'recovered', 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(recovered, 500)).resolves.toBe('recovered');
stuck.resolve('never-settled');
await flush();
expect(await cache.get('session')).toBe('recovered');
});
it('does not store a value produced by a factory that settled after the timeout', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
stuck.resolve('late-produce');
await flush();
expect(await cache.get('session')).toBeNull();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('retries once for the joiners when the producer times out', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
const joiner = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
await expect(producer).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
gates[0].resolve('abandoned-produce');
await flush();
expect(await cache.get('session')).toBe('retried-session');
});
it('releases produce tracking when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const stuck = deferred<string>();
const sibling = deferred<string>();
const abandoned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
const succeeding = cache.getOrSet('session', async () => await sibling.promise, 30, PRODUCE_TIMEOUT_MS * 100);
await expect(abandoned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
sibling.resolve('sibling-produce');
await expect(succeeding).resolves.toBe('sibling-produce');
expect(await cache.get('session')).toBe('sibling-produce');
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('does not hold the event loop open while a produce is in flight', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const timers: Array<NodeJS.Timeout> = [];
const scheduled = globalThis.setTimeout;
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number) => {
const timer = scheduled(handler, ms);
if (ms === PRODUCE_TIMEOUT_MS) {
timers.push(timer);
}
return timer;
}) as typeof globalThis.setTimeout);
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await flush();
spy.mockRestore();
expect(timers).toHaveLength(1);
expect(timers[0].hasRef()).toBe(false);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
});
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
const cache = new InMemoryProvider();
const first = deferred<string>();
const pending = cache.getOrSet('session', async () => await first.promise, 30);
await cache.delete('session');
first.resolve('stale');
await pending;
expect(await cache.get('session')).toBeNull();
await cache.getOrSet('session', async () => 'fresh', 30);
expect(await cache.get('session')).toBe('fresh');
});
});
+6 -6
View File
@@ -6,7 +6,7 @@ import {
validateLockKey,
validateLockToken,
} from '@pkgs/cache/src/CacheLockValidation';
import {ICacheService} from '@pkgs/cache/src/ICacheService';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
interface CacheEntry<T> {
value: T;
@@ -67,14 +67,14 @@ export class InMemoryProvider extends ICacheService {
}
}
async get<T>(key: string): Promise<T | null> {
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (!entry) return {hit: false};
if (this.isExpired(entry)) {
this.cache.delete(key);
return null;
return {hit: false};
}
return entry.value;
return {hit: true, value: entry.value};
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
@@ -86,7 +86,7 @@ export class InMemoryProvider extends ICacheService {
this.cache.set(key, entry);
}
async delete(key: string): Promise<void> {
protected async deleteEntry(key: string): Promise<void> {
this.cache.delete(key);
}
+29 -38
View File
@@ -8,9 +8,10 @@ import {
validateLockToken,
} from '@pkgs/cache/src/CacheLockValidation';
import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTypes';
import {safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {ICacheService} from '@pkgs/cache/src/ICacheService';
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
interface KVCacheProviderConfig {
client: IKVProvider;
@@ -69,16 +70,16 @@ export class KVCacheProvider extends ICacheService {
}
}
async get<T>(key: string): Promise<T | null> {
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
return this.instrumented(
'get',
key,
async () => {
async (): Promise<CacheLookupResult<T>> => {
const value = await this.client.get(key);
if (value == null) return null;
return safeJsonParse<T>(value, this.logger);
if (value == null) return {hit: false};
return parseCachedValue<T>(value, this.logger);
},
(result) => (result == null ? 'miss' : 'hit'),
(result) => (result.hit ? 'hit' : 'miss'),
);
}
@@ -93,7 +94,7 @@ export class KVCacheProvider extends ICacheService {
});
}
async delete(key: string): Promise<void> {
protected async deleteEntry(key: string): Promise<void> {
return this.instrumented('delete', key, async () => {
await this.client.del(key);
});
@@ -137,37 +138,27 @@ export class KVCacheProvider extends ICacheService {
}>,
): Promise<void> {
if (entries.length === 0) return;
const withoutTtl: Array<{
key: string;
value: T;
}> = [];
const withTtl: Array<{
key: string;
value: T;
ttlSeconds: number;
}> = [];
for (const entry of entries) {
if (entry.ttlSeconds) {
withTtl.push({
key: entry.key,
value: entry.value,
ttlSeconds: entry.ttlSeconds,
});
} else {
withoutTtl.push({
key: entry.key,
value: entry.value,
});
const serialized = entries.map((entry) => ({
key: entry.key,
value: serializeValue(entry.value),
ttlSeconds: entry.ttlSeconds,
}));
const batches = splitIntoSlotBatches(serialized, (entry) => entry.key, this.client.isClustered());
await runSlotBatches(batches, async (batch) => {
const pipeline = this.client.pipeline();
for (const entry of batch) {
if (entry.ttlSeconds) {
pipeline.setex(entry.key, entry.ttlSeconds, entry.value);
} else {
pipeline.set(entry.key, entry.value);
}
}
}
const pipeline = this.client.pipeline();
for (const entry of withoutTtl) {
pipeline.set(entry.key, serializeValue(entry.value));
}
for (const entry of withTtl) {
pipeline.setex(entry.key, entry.ttlSeconds, serializeValue(entry.value));
}
await pipeline.exec();
for (const [error] of await pipeline.exec()) {
if (error) {
throw error;
}
}
});
}
async deletePattern(pattern: string): Promise<number> {
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+13 -1
View File
@@ -6,6 +6,12 @@ import cassandra from 'cassandra-driver';
const distance = cassandra.types.distance;
const MAX_REQUESTS_PER_CONNECTION = 2048;
const CONNECT_TIMEOUT_MS = 5000;
const DEFAULT_READ_TIMEOUT_MS = 5000;
export const BACKGROUND_READ_TIMEOUT_MS = 12000;
interface CassandraConfig {
hosts: Array<string>;
port?: number | undefined;
@@ -13,6 +19,7 @@ interface CassandraConfig {
localDc: string;
username?: string | undefined;
password?: string | undefined;
readTimeoutMs?: number | undefined;
}
interface CassandraClientOptions {
@@ -63,6 +70,7 @@ class CassandraClient implements ICassandraClient {
localDc: config.localDc,
username: config.username,
password: config.password,
readTimeoutMs: config.readTimeoutMs,
};
this.logger = options.logger ?? NoopLogger;
this.client = null;
@@ -83,12 +91,16 @@ class CassandraClient implements ICassandraClient {
port: this.config.port ?? 9042,
},
pooling: {
maxRequestsPerConnection: 32768,
maxRequestsPerConnection: MAX_REQUESTS_PER_CONNECTION,
coreConnectionsPerHost: {
[distance.local]: 4,
[distance.remote]: 2,
},
},
socketOptions: {
connectTimeout: CONNECT_TIMEOUT_MS,
readTimeout: this.config.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS,
},
encoding: {
map: Map,
set: Set,
+13
View File
@@ -229,6 +229,19 @@ export class EmailService implements IEmailService {
});
}
async sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
locale: string | null = null,
): Promise<boolean> {
return this.sendTemplatedEmail(email, 'mfa_backup_codes_view', locale, {
username,
code,
expiresAt: new Date(Date.now() + ms('10 minutes')),
});
}
async sendEmailChangeOriginal(
email: string,
username: string,
@@ -92,6 +92,12 @@ export interface IEmailService {
code: string,
locale?: string | null,
): Promise<boolean>;
sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
locale?: string | null,
): Promise<boolean>;
sendDonationMagicLink(
email: string,
token: string,
@@ -223,6 +223,16 @@ export class TestEmailService implements ITestEmailService {
return this.record(email, 'password_change_verification', {code});
}
async sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`MFA backup codes verification sent to ${email} for user ${username}`);
return this.record(email, 'mfa_backup_codes_view', {code});
}
async sendEmailChangeOriginal(
email: string,
username: string,
@@ -59,6 +59,10 @@ export const EMAIL_I18N_MESSAGES = {
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",
},
mfa_backup_codes_view: {
subject: 'Confirm access to your {product_name} backup codes',
body: "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, 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.\n\n– {product_name} Team",
},
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",
@@ -15,6 +15,7 @@ export type EmailTemplateKey =
| 'harvest_completed'
| 'inactivity_warning'
| 'ip_authorization'
| 'mfa_backup_codes_view'
| 'password_change_verification'
| 'password_reset'
| 'registration_approved'
@@ -76,6 +76,11 @@ export interface EmailTemplateVariables {
ipAddress: string;
location: string;
};
mfa_backup_codes_view: {
username: string;
code: string;
expiresAt: Date;
};
password_change_verification: {
username: string;
code: string;
@@ -59,6 +59,10 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
"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"
},
"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"
},
"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"
@@ -59,6 +59,10 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
"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"
},
"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"
},
"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"
@@ -59,6 +59,10 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
"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"
},
"mfa_backup_codes_view": {
"subject": "Confirm access to your {product_name} backup codes",
"body": "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, 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.\n\n– {product_name} Team"
},
"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"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriza el inicio de sesión desde una nueva dirección IP",
"body": "Hola {username}:\n\nDetectamos 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ú, por favor, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, por favor, cambia tu contraseña de inmediato.\n\nEste enlace es válido por 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}",
"body": "Hola {username}:\n\nRecibimos una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
},
"password_change_verification": {
"subject": "Confirma tu cambio de contraseña de {product_name}",
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence a las {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y habilita la autenticación de dos factores.\n\n– Equipo de {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"mfa_backup_codes_view": {
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
"body": "Hola {username}:\n\nHemos recibido una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
},
"password_change_verification": {
"subject": "Confirma tu cambio de contraseña en {product_name}",
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y activa la autenticación de dos factores.\n\n– Equipo de {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
"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"
},
"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"
},
"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"
@@ -59,6 +59,10 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
"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} टीम"
},
"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} टीम"
},
"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} टीम"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriziraj prijavu s nove IP adrese",
"body": "Pozdrav {username},\n\nOtkrili smo pokušaj prijave na tvoj {product_name} račun s nove IP adrese:\n\nIP adresa: {ipAddress}\nLokacija: {location}\n\nAko si to bio ti, potvrdi ovu IP adresu klikom na poveznicu ispod:\n\n{authUrl}\n\nAko se nisi ti pokušao prijaviti, odmah promijeni lozinku.\n\nOva poveznica vrijedi 30 minuta.\n\n– {product_name} Tim"
},
"mfa_backup_codes_view": {
"subject": "Potvrdi pristup svojim {product_name} rezervnim kodovima",
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za prikaz rezervnih kodova na tvom {product_name} računu.\n\nDa bi potvrdio ovaj zahtjev, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod istječe {expiresAt, date, full} u {expiresAt, time, short}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku.\n\n– {product_name} Tim"
},
"password_change_verification": {
"subject": "Potvrdi promjenu lozinke za {product_name}",
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za promjenu lozinke na tvom {product_name} računu.\n\nDa bi potvrdio ovu promjenu, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod vrijedi do {expiresAt}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku i omogući dvofaktorsku autentifikaciju.\n\n– {product_name} Tim"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Új IP-címről való bejelentkezés engedélyezése",
"body": "Szia {username},\n\nBejelentkezési kísérletet észleltünk a {product_name} fiókodba egy új IP-címről:\n\nIP-cím: {ipAddress}\nHelyszín: {location}\n\nHa te voltál, engedélyezd ezt az IP-címet az alábbi linkre kattintva:\n\n{authUrl}\n\nHa nem te próbáltál bejelentkezni, azonnal változtasd meg a jelszavadat.\n\nEz a link 30 percig érvényes.\n\n– {product_name} Csapat"
},
"mfa_backup_codes_view": {
"subject": "Erősítsd meg a {product_name} biztonsági kódjaidhoz való hozzáférést",
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod biztonsági kódjainak megtekintésére.\n\nA kérés megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt, date, full} {expiresAt, time, short} időpontban jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat.\n\n– {product_name} Csapat"
},
"password_change_verification": {
"subject": "Erősítsd meg a {product_name} jelszócserédet",
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod jelszavának megváltoztatására.\n\nA változtatás megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt}-kor jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat, és engedélyezd a kétfaktoros hitelesítést.\n\n– {product_name} Csapat"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Otorisasi masuk dari alamat IP barumu",
"body": "Halo {username},\n\nKami mendeteksi upaya masuk ke akun {product_name}mu dari alamat IP baru:\n\nAlamat IP: {ipAddress}\nLokasi: {location}\n\nKalau ini kamu, otorisasi alamat IP ini dengan mengeklik tautan di bawah ini:\n\n{authUrl}\n\nJika kamu tidak mencoba masuk, segera ubah kata sandimu.\n\nTautan ini berlaku selama 30 menit.\n\n– Tim {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Konfirmasi akses ke kode cadangan {product_name}mu",
"body": "Halo {username},\n\nKami menerima permintaan untuk melihat kode cadangan di akun {product_name}mu.\n\nUntuk mengonfirmasi permintaan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt, date, full} pukul {expiresAt, time, short}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera.\n\n– Tim {product_name}"
},
"password_change_verification": {
"subject": "Konfirmasi perubahan kata sandi {product_name}mu",
"body": "Halo {username},\n\nKami menerima permintaan untuk mengubah kata sandi di akun {product_name}mu.\n\nUntuk mengonfirmasi perubahan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera dan aktifkan autentikasi dua faktor.\n\n– Tim {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizza l'accesso da un nuovo indirizzo IP",
"body": "Ciao {username},\n\nAbbiamo rilevato un tentativo di accesso al tuo account {product_name} da un nuovo indirizzo IP:\n\nIndirizzo IP: {ipAddress}\nPosizione: {location}\n\nSe sei stato tu, autorizza questo indirizzo IP cliccando sul link qui sotto:\n\n{authUrl}\n\nSe non hai tentato di accedere, cambia subito la password.\n\nQuesto link è valido per 30 minuti.\n\n– Team {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Conferma l'accesso ai tuoi codici di backup di {product_name}",
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di visualizzazione dei codici di backup del tuo account {product_name}.\n\nPer confermare questa richiesta, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade il {expiresAt, date, full} alle {expiresAt, time, short}.\n\nSe non hai richiesto questa operazione, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password.\n\n– Team {product_name}"
},
"password_change_verification": {
"subject": "Conferma la modifica della password di {product_name}",
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di modifica della password del tuo account {product_name}.\n\nPer confermare questa modifica, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade alle {expiresAt}.\n\nSe non hai richiesto questa modifica, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password e abilita l'autenticazione a due fattori.\n\n– Team {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
"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}チーム"
},
"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}チーム"
},
"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}チーム"
@@ -59,6 +59,10 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
"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} 팀"
},
"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} 팀"
},
"password_change_verification": {
"subject": "{product_name} 비밀번호 변경을 확인해 주세요",
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 비밀번호 변경 요청이 접수되었어요.\n\n이 변경을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하고 2단계 인증을 활성화하세요.\n\n– {product_name} 팀"
@@ -59,6 +59,10 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Leisti prisijungimą iš naujo IP adreso",
"body": "Sveikas, {username},\n\nAptikome bandymą prisijungti prie tavo {product_name} paskyros iš naujo IP adreso:\n\nIP adresas: {ipAddress}\nVieta: {location}\n\nJei tai buvai tu, patvirtink šį IP adresą spustelėdamas žemiau esančią nuorodą:\n\n{authUrl}\n\nJei nebandei prisijungti, nedelsdamas pakeisk slaptažodį.\n\nŠi nuoroda galioja 30 minučių.\n\n– {product_name} komanda"
},
"mfa_backup_codes_view": {
"subject": "Patvirtink prieigą prie savo {product_name} atsarginių kodų",
"body": "Sveikas, {username},\n\nGavome prašymą peržiūrėti tavo {product_name} paskyros atsarginius kodus.\n\nNorėdamas patvirtinti šį prašymą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt, date, full} {expiresAt, time, short}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį.\n\n– {product_name} komanda"
},
"password_change_verification": {
"subject": "Patvirtink savo {product_name} slaptažodžio keitimą",
"body": "Sveikas, {username},\n\nGavome prašymą pakeisti tavo {product_name} paskyros slaptažodį.\n\nNorėdamas patvirtinti šį pakeitimą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį ir įjunk dviejų veiksnių autentifikavimą.\n\n– {product_name} komanda"
@@ -59,6 +59,10 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriseer inlog vanaf een nieuw IP-adres",
"body": "Hallo {username},\n\nWe hebben een inlogpoging op je {product_name}-account gedetecteerd vanaf een nieuw IP-adres:\n\nIP-adres: {ipAddress}\nLocatie: {location}\n\nAls jij dit was, autoriseer dan dit IP-adres door op de onderstaande link te klikken:\n\n{authUrl}\n\nAls je niet hebt geprobeerd in te loggen, wijzig dan onmiddellijk je wachtwoord.\n\nDeze link is 30 minuten geldig.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bevestig toegang tot je {product_name}-back-upcodes",
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om de back-upcodes van je {product_name}-account te bekijken.\n\nOm dit verzoek te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt op {expiresAt, date, full} om {expiresAt, time, short}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bevestig je {product_name}-wachtwoordwijziging",
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om het wachtwoord van je {product_name}-account te wijzigen.\n\nOm deze wijziging te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt om {expiresAt}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord en schakel tweefactorauthenticatie in.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Godkjenn pålogging fra en ny IP-adresse",
"body": "Hei {username},\n\nVi oppdaget et påloggingsforsøk til {product_name}-kontoen din fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var deg, godkjenn denne IP-adressen ved å klikke på lenken nedenfor:\n\n{authUrl}\n\nHvis du ikke forsøkte å logge inn, endre passordet ditt med en gang.\n\nDenne lenken er gyldig i 30 minutter.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bekreft tilgang til {product_name}-reservekodene dine",
"body": "Hei {username},\n\nVi mottok en forespørsel om å se reservekodene på {product_name}-kontoen din.\n\nFor å bekrefte denne forespørselen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bekreft din {product_name}-passordendring",
"body": "Hei {username},\n\nVi mottok en forespørsel om å endre passordet på {product_name}-kontoen din.\n\nFor å bekrefte denne endringen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang og aktiver tofaktorautentisering.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoryzuj logowanie z nowego adresu IP",
"body": "Witaj {username},\n\nWykryliśmy próbę logowania do Twojego konta {product_name} z nowego adresu IP:\n\nAdres IP: {ipAddress}\nLokalizacja: {location}\n\nJeśli to Ty, autoryzuj ten adres IP, klikając poniższy link:\n\n{authUrl}\n\nJeśli nie próbowałeś się zalogować, natychmiast zmień hasło.\n\nTen link jest ważny przez 30 minut.\n\n– Zespół {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Potwierdź dostęp do kodów zapasowych w {product_name}",
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o wyświetlenie kodów zapasowych Twojego konta {product_name}.\n\nAby potwierdzić tę prośbę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa {expiresAt, date, full} o {expiresAt, time, short}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło.\n\n– Zespół {product_name}"
},
"password_change_verification": {
"subject": "Potwierdź zmianę hasła w {product_name}",
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o zmianę hasła do Twojego konta {product_name}.\n\nAby potwierdzić tę zmianę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa o godzinie {expiresAt}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło i włącz uwierzytelnianie dwuskładnikowe.\n\n– Zespół {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizar login de um novo endereço IP",
"body": "Olá, {username},\n\nDetectamos uma tentativa de login em sua conta do {product_name} de um novo endereço IP:\n\nEndereço IP: {ipAddress}\nLocalização: {location}\n\nSe foi você, por favor, autorize este endereço IP clicando no link abaixo:\n\n{authUrl}\n\nSe você não tentou entrar, por favor, altere sua senha imediatamente.\n\nEste link é válido por 30 minutos.\n\n– Equipe do {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirme o acesso aos códigos de backup da sua conta do {product_name}",
"body": "Olá, {username},\n\nRecebemos uma solicitação para visualizar os códigos de backup da sua conta do {product_name}.\n\nPara confirmar esta solicitação, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt, date, full} às {expiresAt, time, short}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente.\n\n– Equipe do {product_name}"
},
"password_change_verification": {
"subject": "Confirme a alteração da senha da sua conta do {product_name}",
"body": "Olá, {username},\n\nRecebemos uma solicitação para alterar a senha da sua conta do {product_name}.\n\nPara confirmar esta alteração, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente e ative a autenticação de dois fatores.\n\n– Equipe do {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizează conectarea de la o nouă adresă IP",
"body": "Salut {username},\n\nAm detectat o tentativă de conectare la contul tău {product_name} de la o nouă adresă IP:\n\nAdresă IP: {ipAddress}\nLocație: {location}\n\nDacă ai fost tu, te rugăm să autorizezi această adresă IP apăsând pe linkul de mai jos:\n\n{authUrl}\n\nDacă nu ai încercat să te conectezi, te rugăm să-ți schimbi parola imediat.\n\nAcest link este valabil 30 de minute.\n\n– Echipa {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirmă accesul la codurile tale de rezervă pentru {product_name}",
"body": "Salut {username},\n\nAm primit o solicitare de vizualizare a codurilor de rezervă ale contului tău {product_name}.\n\nPentru a confirma această solicitare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la data de {expiresAt, date, full} la ora {expiresAt, time, short}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat.\n\n– Echipa {product_name}"
},
"password_change_verification": {
"subject": "Confirmă schimbarea parolei pentru {product_name}",
"body": "Salut {username},\n\nAm primit o solicitare de schimbare a parolei contului tău {product_name}.\n\nPentru a confirma această modificare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la {expiresAt}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat și activează autentificarea cu doi factori.\n\n– Echipa {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Auktorisera inloggning från en ny IP-adress",
"body": "Hej {username},\n\nVi upptäckte ett inloggningsförsök till ditt {product_name}-konto från en ny IP-adress:\n\nIP-adress: {ipAddress}\nPlats: {location}\n\nOm detta var du, godkänn denna IP-adress genom att klicka på länken nedan:\n\n{authUrl}\n\nOm du inte försökte logga in, ändra ditt lösenord omedelbart.\n\nDenna länk är giltig i 30 minuter.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bekräfta åtkomst till dina {product_name}-reservkoder",
"body": "Hej {username},\n\nVi har mottagit en begäran om att visa reservkoderna för ditt {product_name}-konto.\n\nFör att bekräfta denna begäran, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bekräfta din {product_name}-lösenordsändring",
"body": "Hej {username},\n\nVi har mottagit en begäran om att ändra lösenordet för ditt {product_name}-konto.\n\nFör att bekräfta denna ändring, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart och aktivera tvåfaktorsautentisering.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Yeni bir IP adresinden girişi yetkilendir",
"body": "Merhaba {username},\n\n{product_name} hesabına yeni bir IP adresinden giriş denemesi tespit ettik:\n\nIP adresi: {ipAddress}\nKonum: {location}\n\nBu sen isen, lütfen aşağıdaki bağlantıya tıklayarak bu IP adresini yetkilendir:\n\n{authUrl}\n\nGiriş yapmaya sen çalışmadıysan, lütfen hemen şifreni değiştir.\n\nBu bağlantı 30 dakika boyunca geçerlidir.\n\n– {product_name} Ekibi"
},
"mfa_backup_codes_view": {
"subject": "{product_name} yedek kodlarına erişimi onayla",
"body": "Merhaba {username},\n\n{product_name} hesabındaki yedek kodları görüntüleme isteği aldık.\n\nBu isteği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt, date, full} {expiresAt, time, short} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir.\n\n– {product_name} Ekibi"
},
"password_change_verification": {
"subject": "{product_name} şifre değişikliğini onayla",
"body": "Merhaba {username},\n\n{product_name} hesabının şifresini değiştirme isteği aldık.\n\nBu değişikliği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir ve iki faktörlü kimlik doğrulamayı etkinleştir.\n\n– {product_name} Ekibi"
@@ -59,6 +59,10 @@ const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
"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}"
},
"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}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Cho phép đăng nhập từ địa chỉ IP mới",
"body": "Xin chào {username},\n\nChúng tôi đã phát hiện một nỗ lực đăng nhập vào tài khoản {product_name} của bạn từ một địa chỉ IP mới:\n\nĐịa chỉ IP: {ipAddress}\nVị trí: {location}\n\nNếu đây là bạn, vui lòng cho phép địa chỉ IP này bằng cách nhấp vào liên kết bên dưới:\n\n{authUrl}\n\nNếu bạn không thực hiện nỗ lực đăng nhập, vui lòng thay đổi mật khẩu ngay lập tức.\n\nLiên kết này có hiệu lực trong 30 phút.\n\n– Đội ngũ {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Xác nhận quyền truy cập mã dự phòng {product_name} của bạn",
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu xem mã dự phòng trên tài khoản {product_name} của bạn.\n\nĐể xác nhận yêu cầu này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn vào {expiresAt, date, full} lúc {expiresAt, time, short}.\n\nNếu bạn không yêu cầu điều này, ai đó có thể đã truy cập vào tài khoản của bạn. Thay đổi mật khẩu ngay lập tức.\n\n– Đội ngũ {product_name}"
},
"password_change_verification": {
"subject": "Xác nhận thay đổi mật khẩu của bạn trên {product_name}",
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu thay đổi mật khẩu trên tài khoản {product_name} của bạn.\n\nĐể xác nhận thay đổi này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn lúc {expiresAt}.\n\nNếu bạn không yêu cầu thay đổi này, ai đó có thể đã truy cập vào tài khoản của bạn. Thay đổi mật khẩu ngay lập tức và bật xác thực hai yếu tố.\n\n– Đội ngũ {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
"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} 团队"
},
"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} 团队"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
"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} 團隊"
},
"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} 團隊"
@@ -1,86 +1,90 @@
{
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"email_change_revert": {
"subject": "تم تغيير بريدك الإلكتروني في {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}"
},
"gift_chargeback_notification": {
"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}"
},
"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 جديد",
"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}"
},
"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}"
},
"password_reset": {
"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}"
},
"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}"
},
"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}"
},
"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}"
}
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"email_change_revert": {
"subject": "تم تغيير بريدك الإلكتروني في {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}"
},
"gift_chargeback_notification": {
"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}"
},
"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 جديد",
"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}"
},
"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}"
},
"password_reset": {
"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}"
},
"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}"
},
"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}"
},
"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}"
}
}
@@ -1,86 +1,90 @@
{
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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 адрес",
"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}"
},
"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}"
},
"password_reset": {
"subject": "Нулирай паролата си във {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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
}
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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 адрес",
"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}"
},
"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}"
},
"password_reset": {
"subject": "Нулирай паролата си във {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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
}
}
@@ -1,86 +1,90 @@
{
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
}
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
},
"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}"
}
}
@@ -1,86 +1,90 @@
{
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
}
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
}
}
@@ -1,86 +1,90 @@
{
"account_disabled_suspicious": {
"subject": "Dein {product_name}-Account wurde vorübergehend deaktiviert",
"body": "Hallo {username},\n\nwir haben deinen {product_name}-Account vorübergehend deaktiviert, da wir verdächtige Aktivitäten festgestellt haben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nUm wieder Zugriff auf deinen Account zu erhalten, musst du dein Passwort zurücksetzen:\n\n{forgotUrl}\n\nNachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.\n\nWenn du glaubst, dass dies ein Fehler war, kontaktiere bitte unser Support-Team.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"account_temp_banned": {
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
"body": "Hallo {username},\n\ndein {product_name}-Account wurde vorübergehend gesperrt, weil du gegen unsere Nutzungsbedingungen oder Community-Richtlinien verstoßen hast.\n\nDauer: {durationHours, plural,\n one {1 Stunde}\n other {# Stunden}\n}\nGesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nWährend dieser Zeit kannst du nicht auf deinen Account zugreifen.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email} und erkläre klar, warum du die Entscheidung für falsch hältst. Wir werden deinen Einspruch prüfen und dir unsere Entscheidung mitteilen.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"scheduled_deletion_notification": {
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
"body": "Hallo {username},\n\ndein {product_name}-Account wurde 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\nWenn du glaubst, dass diese Entscheidung falsch war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"unban_notification": {
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
"body": "Hallo {username},\n\ngute Nachrichten: Die Sperrung deines {product_name}-Accounts wurde aufgehoben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDu kannst dich jetzt wieder anmelden und {product_name} wie gewohnt nutzen.\n\n– {product_name}-Sicherheitsteam"
}
"account_disabled_suspicious": {
"subject": "Dein {product_name}-Account wurde vorübergehend deaktiviert",
"body": "Hallo {username},\n\nwir haben deinen {product_name}-Account vorübergehend deaktiviert, da wir verdächtige Aktivitäten festgestellt haben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nUm wieder Zugriff auf deinen Account zu erhalten, musst du dein Passwort zurücksetzen:\n\n{forgotUrl}\n\nNachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.\n\nWenn du glaubst, dass dies ein Fehler war, kontaktiere bitte unser Support-Team.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"account_temp_banned": {
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
"body": "Hallo {username},\n\ndein {product_name}-Account wurde vorübergehend gesperrt, weil du gegen unsere Nutzungsbedingungen oder Community-Richtlinien verstoßen hast.\n\nDauer: {durationHours, plural,\n one {1 Stunde}\n other {# Stunden}\n}\nGesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nWährend dieser Zeit kannst du nicht auf deinen Account zugreifen.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email} und erkläre klar, warum du die Entscheidung für falsch hältst. Wir werden deinen Einspruch prüfen und dir unsere Entscheidung mitteilen.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"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"
},
"scheduled_deletion_notification": {
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
"body": "Hallo {username},\n\ndein {product_name}-Account wurde 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\nWenn du glaubst, dass diese Entscheidung falsch war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\n– {product_name}-Sicherheitsteam"
},
"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"
},
"unban_notification": {
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
"body": "Hallo {username},\n\ngute Nachrichten: Die Sperrung deines {product_name}-Accounts wurde aufgehoben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDu kannst dich jetzt wieder anmelden und {product_name} wie gewohnt nutzen.\n\n– {product_name}-Sicherheitsteam"
}
}

Some files were not shown because too many files have changed in this diff Show More