ARRFLIX/docs/19-english-only-audit.md
s8n a3f82dfcc0 doc 19: english-only lockdown audit (read-only baseline)
Cross-layer audit supplementing docs 15 and 16. Confirms doc-15 root
cause still live (8/8 users have UICulture absent; force-english script
unrun), enumerates 93 served locale chunks (de-json contains 'Abspielen'),
and proposes 4-pronged remediation: per-user POST + wrapper patch +
Traefik Accept-Language rewrite + navigator.language shim.

Read-only. No Jellyfin mutations performed.
2026-05-08 17:05:11 +01:00

18 KiB
Raw Blame History

19 - English-Only Lockdown Audit (Read-Only Baseline)

Owner saw the Play button render as "Abspielen" (German). Goal: "everything English only, remove the ability to be in another language at all". This doc supplements docs/15-force-english.md and docs/16-jellyfin-branding-leaks.md — it is the cross-layer baseline for the lockdown branch.

Audited: 2026-05-08 against live https://arrflix.s8n.ru, Jellyfin 10.10.3. Auditor: s8n. Mode: read-only. No POST/PATCH/PUT to Jellyfin, no file modifications outside this doc.


TL;DR — root cause + why doc 15 didn't close it

  1. Per-user Configuration.UICulture is still absent on every account. All 8 users return Configuration.UICulture as a missing key (verified live 2026-05-08, see Per-User Table below). Doc 15 correctly identified the fix and shipped the bin/force-english-all-users.sh script — but the script was never executed. There is no audit trail of a 204 No Content against /Users/{id}/Configuration in the activity log for any user, and the live state proves it (UICulture still absent on all 8). When UICulture is absent, the SPA falls back to navigator.language / Accept-Language, so any browser sending de-* loads the German bundle and renders "Abspielen". This is layer (5) in the table below.

  2. The German translation bundle is shipped and live. de-json.1afccc006ab8bb6c5953.chunk.js is reachable, returns HTTP 200, and contains "Play":"Abspielen", "Settings":"Einstellungen", "Save":"Speichern", etc. — 1963 unique translated keys. 92 other locale chunks ship alongside it. Until those are removed from the served bundle, the SPA can always select a non-English locale even if every user has UICulture=en-US (e.g. a new user who never authenticated, or a tampered SPA). Doc 15 explicitly noted "no server flag forces SPA to ignore Accept-Language" but stopped at the per-user pin — it didn't propose deleting the bundles.

In two sentences: The Play button renders "Abspielen" because every user has Configuration.UICulture absent so the SPA defers to the browser's Accept-Language: de-*, and bin/force-english-all-users.sh (the doc-15 fix) was authored but never run. Even after running it, 92 non-English locale chunks remain reachable on the bind-mounted web bundle, leaving pre-auth and edge-case surfaces still German-capable.


Per-Layer Findings

# Layer Current Value Desired How to Fix Owner
1 Server /System/Configuration.UICulture en-US en-US Already correct (admin dashboard locale; does NOT cascade to users — see doc 15 §3) server (none — already correct)
2 Server /System/Configuration.PreferredMetadataLanguage en en Already correct server (none)
3 Server /System/Configuration.MetadataCountryCode US US Already correct server (none)
4 Server /Branding/Configuration.LoginDisclaimer "Welcome to ARRFLIX - Private invite only service" English already OK server (none)
5 Per-user Configuration.UICulture (8/8 absent) all absent en-US on every user Run bin/force-english-all-users.sh with admin token; idempotent. Endpoint: POST /Users/{userId}/Configuration with full Configuration block + UICulture:"en-US". server agent — primary fix
6 Per-user Configuration.AudioLanguagePreference (8/8) eng eng Already correct server (none)
7 Per-user Configuration.SubtitleLanguagePreference (8/8) eng eng Already correct server (none)
8 Per-user Configuration.PlayDefaultAudioTrack (8/8) true true Already correct server (none)
9 Per-user Configuration.SubtitleMode (8/8) Default Default Already correct server (none)
10 Per-user DisplayPreferences.CustomPrefs.language (key not present for any user) (still not present) Confirmed read-only of all 8 users via GET /DisplayPreferences/usersettings?userId=...&client=emby — no language key in CustomPrefs. Locale is NOT stored here. Layer is non-issue. none
11 Plugin-shipped UI strings 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb); none ship locale UI strings None No action — these are metadata-source plugins, not UI string sources. none
12 Available /Localization/Cultures 191 191 (cosmetic — admin-only) API returns the full ISO list regardless of disk content. Cannot be trimmed via API. Admin-only. Defer. docs (no action)
13 Available /Localization/Options (display lang) 71 1 (en-US only, ideally) Same as 12 — API list is hardcoded in Jellyfin. Cannot be trimmed via API. But the user-facing dropdown that uses this list is on mypreferencesmenu.html which is already hidden by the inject-shim. Non-issue for non-admins; admin keeps full list. none — already gated by shim
14 Available /Localization/Countries 139 139 Cosmetic; admin-only. No action. none
15 SPA index.html HTML response identical for Accept-Language: de-DE and en-US identical Confirmed: curl -H 'Accept-Language: de-*' and en-US return byte-identical 59757-byte HTML. Locale selection happens client-side in JS, not server-side. So there is no server header rewrite to add. web (none)
16 Web bundle locale chunks <lang>-json.<hash>.chunk.js 93 locale chunks served (de, fr, es, ru, zh-cn, ja, ko, ...) including de-json.1afccc006ab8bb6c5953.chunk.js containing "Play":"Abspielen" only en-us-json.<hash>.chunk.js reachable; all others 404 Override 92 non-English chunks to empty/redirect at the bind-mount layer (see "Files to Delete" §). Compose pattern: bind-mount each as :/jellyfin/jellyfin-web/<lang>-json.<hash>.chunk.js:ro from a 1-byte {} stub. Drawback: chunk hashes rotate on JF upgrade — record filenames in web-overrides/README.md and re-pin after each image bump. Cleaner alternative: add a Traefik middleware regexReplaceHeaders rule that 404s any *-json.*.chunk.js whose lang prefix isn't en-us. web agent — secondary fix (defense in depth)
17 PWA manifest lang "lang": "en-US" in fd4301fdc170fd202474.json "lang": "en-US" (and name/short_name rebranded — see doc 16 F1) manifest lang is already correct, but name/short_name are still Jellyfin. Folded into doc 16 F1, not duplicated here. web (doc 16 work)
18 Pre-auth splash bundle strings reads navigator.language before any user is authed en-US only Doc 15 §"What CANNOT be forced" §1 noted this is unfixable without a runtime shim that overrides navigator.language. NEW PROPOSAL: patch bin/inject-shim.py to inject Object.defineProperty(navigator, 'language', { value: 'en-US' }); Object.defineProperty(navigator, 'languages', { value: ['en-US'] }); BEFORE any other JS executes. The inject-shim runs in <head> before bundles load, so this is the right vehicle. web agent — closes pre-auth leak
19 Reverse-proxy Accept-Language passed through unchanged (Traefik) rewrite to en-US doc 15 §"What CANNOT be forced" §2 already evaluated and rejected this as too aggressive for the multi-tenant Traefik. Re-evaluation: ARRFLIX is the only consumer of arrflix.s8n.ru via this Traefik router; rewriting Accept-Language at the router level is safe and would mean (5) and (16) and (18) are all redundant defense-in-depth. Add a traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9 middleware. web agent — alternative single-layer fix
20 New-user creation script bin/add-jellyfin-user.sh does NOT set UICulture sets UICulture="en-US" doc 15 already documented the one-line patch in step [3/4]. Apply the diff. server agent (doc 15 work)

Per-User Table (live state, 2026-05-08)

User UserId UICulture Audio Pref Subtitle Pref needs-update
5 571decc67cdc4ea683b4c936b0a31ff8 absent eng eng Y
64bitpotato 106e347364a643fda324a7a1de3422f6 absent eng eng Y
aloy 5447c6246a704533a149910155d5422e absent eng eng Y
guest 82dd8542915740c8ae799b6723542c63 absent eng eng Y
house a4cbcdf95bb34888885af6fbf5c340d1 absent eng eng Y
marco d787fbfc373a44119a247e7406b2721e absent eng eng Y
pet d60e249518264357a6072a08829d43ec absent eng eng Y
s8n (admin) 2be0f0d3fe3a45dc9298138a15a01925 absent eng eng Y

Count needing update: 8 of 8 users. This is the entire active user roster. Doc 15 (2026-05-08) listed only 5 users (5, guest, house, marco, s8n); the roster has since grown to 8 (added: 64bitpotato, aloy, pet). All 3 new users were created via bin/add-jellyfin-user.sh without the doc-15 wrapper patch (UICulture line not added), so they also inherit the bug.


Remediation Checklist (concrete endpoints/bodies for sibling agents)

Do not execute from this audit doc. Sibling agents own implementation.

Server agent — primary fix (closes layer 5, single biggest impact)

# All 8 users in one go (idempotent):
JELLYFIN_TOKEN='${JELLYFIN_API_TOKEN}' bin/force-english-all-users.sh

# Spot-verify one user post-fix (expect "en-US"):
curl -ks https://arrflix.s8n.ru/Users/d787fbfc373a44119a247e7406b2721e \
  -H "Authorization: MediaBrowser Token=${JELLYFIN_API_TOKEN}" \
  | jq -r '.Configuration.UICulture'

After this lands, every authenticated session is pinned to en-US regardless of browser. Pre-auth and chunk-bundle leaks (16, 18) remain.

Server agent — wrapper patch (closes layer 20, prevents regression)

Apply the doc-15 §"Wrapper update for future users" one-line patch to bin/add-jellyfin-user.sh step [3/4]:

c['UICulture'] = 'en-US'   # NEW: pin UI to English regardless of browser Accept-Language

Web agent — defense-in-depth chunk lockdown (closes layer 16)

Two paths, pick one:

Path A — Traefik middleware (preferred, single point of control):

# In docker-compose.yml jellyfin labels:
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"
- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"

Pros: one line, no bind-mounts to maintain, immune to JF upgrades. Cons: doesn't help with chunk filename leak if the bundle ever fingerprints on something other than Accept-Language.

Path B — chunk bind-mount stubs (heavy but airtight):

For each non-English chunk in web-overrides/README.md (record list per JF image upgrade), bind a 1-byte {} stub:

- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/de-json.1afccc006ab8bb6c5953.chunk.js:ro
- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/fr-json.<hash>.chunk.js:ro
... (×91 more)

Where empty-chunk.js contents:

(self.webpackChunk=self.webpackChunk||[]).push([[XXXXX],{}]);

(XXXXX = chunk-id from runtime.bundle.js for that locale; chunk-ids listed in §"Files to Delete" below.)

Recommended: ship Path A first as the cheap belt; defer Path B to Phase 2 unless the owner specifically wants the chunk files unreachable.

Web agent — pre-auth splash fix (closes layer 18)

Append to the IIFE in bin/inject-shim.py, before the start() block:

// Override navigator.language BEFORE webpack bundles read it
try {
  Object.defineProperty(navigator, 'language', {
    value: 'en-US', configurable: false, writable: false
  });
  Object.defineProperty(navigator, 'languages', {
    value: ['en-US', 'en'], configurable: false, writable: false
  });
} catch(e){}

Combined with Path A above (Accept-Language rewrite at proxy), pre-auth splash strings render in English on first paint.

Docs agent — supersedes notes

After the above lands, update doc 15 with a "Status: applied 2026-05-XX" header and link forward to this doc. Update doc 16 F1 cross-ref since the manifest name/short_name work overlaps with the lockdown branch.


Files to Delete (locale bundles served by web SPA)

92 non-English locale chunks served from /jellyfin/jellyfin-web/. Hashes were captured from the live runtime.bundle.js chunk-id-to-hash map on 2026-05-08; these will rotate on every JF image upgrade — regenerate this list before each upgrade with:

curl -ks 'https://arrflix.s8n.ru/web/runtime.bundle.js?<query>' | python3 -c "
import re, sys
txt = sys.stdin.read()
hashmap = dict(re.findall(r'(\d+):\"([a-f0-9]{20})\"', txt))
namemap = dict(re.findall(r'(\d+):\"([a-zA-Z0-9_-]+-json)\"', txt))
for cid, name in sorted(namemap.items(), key=lambda x: x[1]):
    if not name.startswith('en-us'):
        print(f'{name}.{hashmap[cid]}.chunk.js')
"

The single chunk to keep is en-us-json.667484b4a441712c7e05.chunk.js.

The 92 chunks to drop (current hashes — re-extract on upgrade):

af-json.c51579ebcde4cc473828.chunk.js
ar-json.1e4d5a6f9a6acf5777ba.chunk.js
as-json.c9ec5dcf74b613f34865.chunk.js
be-by-json.04e26c1f665c26cef640.chunk.js
bg-bg-json.8f63ff103b1093a4367b.chunk.js
bn-json.<hash>.chunk.js
bn_BD-json.<hash>.chunk.js
ca-json.<hash>.chunk.js
ch-json.<hash>.chunk.js
cs-json.<hash>.chunk.js
cy-json.<hash>.chunk.js
da-json.<hash>.chunk.js
de-json.1afccc006ab8bb6c5953.chunk.js   ← THE ONE THAT BIT US (contains "Play":"Abspielen")
el-json.<hash>.chunk.js
en-gb-json.<hash>.chunk.js               ← keep? en-GB is also English; defer to owner. If owner wants only en-US, drop.
eo-json.<hash>.chunk.js
es-ar-json.<hash>.chunk.js
es-json.<hash>.chunk.js
es-mx-json.<hash>.chunk.js
es_419-json.<hash>.chunk.js
es_DO-json.<hash>.chunk.js
et-json.<hash>.chunk.js
eu-json.<hash>.chunk.js
fa-json.<hash>.chunk.js
fi-json.<hash>.chunk.js
fil-json.<hash>.chunk.js
fo-json.<hash>.chunk.js
fr-ca-json.<hash>.chunk.js
fr-json.<hash>.chunk.js
ga-json.<hash>.chunk.js
gl-json.<hash>.chunk.js
gsw-json.<hash>.chunk.js
gu-json.<hash>.chunk.js
he-json.<hash>.chunk.js
hi-in-json.<hash>.chunk.js
hr-json.<hash>.chunk.js
hu-json.<hash>.chunk.js
hy-json.<hash>.chunk.js
id-json.<hash>.chunk.js
is-is-json.<hash>.chunk.js
it-json.<hash>.chunk.js
ja-json.<hash>.chunk.js
jbo-json.<hash>.chunk.js
ka-json.<hash>.chunk.js
kab-json.<hash>.chunk.js
kk-json.<hash>.chunk.js
kn-json.<hash>.chunk.js
ko-json.<hash>.chunk.js
lt-lt-json.<hash>.chunk.js
lv-json.<hash>.chunk.js
mk-json.<hash>.chunk.js
ml-json.<hash>.chunk.js
mn-mn-json.<hash>.chunk.js
mr-json.<hash>.chunk.js
ms-json.<hash>.chunk.js
nb-json.<hash>.chunk.js
ne-json.<hash>.chunk.js
nl-json.<hash>.chunk.js
nn-json.<hash>.chunk.js
pa-json.<hash>.chunk.js
pl-json.<hash>.chunk.js
pr-json.<hash>.chunk.js
pt-br-json.<hash>.chunk.js
pt-json.<hash>.chunk.js
pt-pt-json.<hash>.chunk.js
ro-json.<hash>.chunk.js
ru-json.<hash>.chunk.js
si-json.<hash>.chunk.js
sk-json.<hash>.chunk.js
sl-si-json.<hash>.chunk.js
so-json.<hash>.chunk.js
sq-json.<hash>.chunk.js
sr-json.<hash>.chunk.js
sv-json.<hash>.chunk.js
sw-json.<hash>.chunk.js
ta-json.<hash>.chunk.js
te-json.<hash>.chunk.js
th-json.<hash>.chunk.js
tr-json.<hash>.chunk.js
uk-json.<hash>.chunk.js
ur_PK-json.<hash>.chunk.js
uz-json.<hash>.chunk.js
vi-json.5ce142c3b4228beafe7a.chunk.js
zh-cn-json.9ef4c0ef42cc04d64912.chunk.js
zh-hk-json.faa0648f6b0f186e6c07.chunk.js
zh-tw-json.d07cd62eb7dd68687b64.chunk.js
zu-json.0c869775f5145121570c.chunk.js
... (full 92-line list saved to web-overrides/README.md when web agent regenerates)

The full count is 93 chunk files served at runtime; one (en-us-json.<hash>) is kept, 92 are dropped. Decision required from owner: drop en-gb-json too, or accept en-GB as a tolerable secondary English locale? Doc 15 line 19 mentioned TARGET_LOCALE=en-GB is an alternate option, suggesting en-GB is not categorically rejected. Default recommendation: drop en-gb too — "English only, en-US canonical".


Cross-References

  • docs/15-force-english.md — original per-user UICulture diagnosis + bin/force-english-all-users.sh (script exists, not yet run) + wrapper patch for bin/add-jellyfin-user.sh (not yet applied). This audit confirms doc 15's diagnosis is still accurate and adds the user-count update (5 → 8).
  • docs/16-jellyfin-branding-leaks.md — covers the Jellyfin word in PWA manifest name/short_name (F1), screensaver banner (F2), i18n keys containing "Jellyfin" in en-us-json chunk (F3). The PWA manifest lang field is already en-US so no action overlap; only the name/short_name work overlaps with this doc's branding-vs-locale axis. F3's DOM text rewrite shim is orthogonal — it strips the word Jellyfin from English strings, while this doc strips non-English strings entirely.
  • docs/10-spa-runtime-shim.md — vehicle for the proposed Object.defineProperty(navigator, 'language', …) snippet (see Layer 18). Same inject-shim.py already in use; one new try/catch block.
  • docs/04-theming-and-users.md — CustomCss is unrelated to locale; no overlap, no action.

Sign-off

  • Audit run by: s8n, 2026-05-08, admin token via X-Emby-Token header.
  • Mode: read-only. Zero POST/PATCH/PUT to Jellyfin. Zero file modifications outside this docs/19-english-only-audit.md.
  • Live state: all 8 users at UICulture-absent (root cause confirmed); 93 locale bundles served (1 keep / 92 drop); SPA index.html serves byte-identical regardless of Accept-Language (locale is client-side); doc-15 fix exists but unrun; doc-15 wrapper patch unapplied.
  • Recommended next action: server agent runs bin/force-english-all-users.sh and applies the wrapper patch (closes 80% of the leak in 30 seconds). Web agent adds the Traefik Accept-Language middleware (Path A) and the navigator.language shim (closes the remaining pre-auth leak). Defer chunk bind-mounts (Path B) to Phase 2.