summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/ollama_web/static/i18n.js25
-rw-r--r--src/ollama_web/templates/index.html6
-rw-r--r--src/ollama_web/templates/login.html4
-rw-r--r--tests/test_i18n_rendering.py61
4 files changed, 86 insertions, 10 deletions
diff --git a/src/ollama_web/static/i18n.js b/src/ollama_web/static/i18n.js
index 985b452..7f8d013 100644
--- a/src/ollama_web/static/i18n.js
+++ b/src/ollama_web/static/i18n.js
@@ -1,17 +1,35 @@
/** Small i18n helper for the browser.
*
- * The server injects ``window.I18N`` as a nested message object. This module
- * exposes ``t(key, fallback)`` to resolve dot-separated keys safely.
+ * The server stores the nested message object as JSON in a non-executable
+ * meta element. This module parses it and exposes ``t(key, fallback)`` to
+ * resolve dot-separated keys safely without requiring inline JavaScript.
*/
(function () {
"use strict";
+ const dataEl = document.querySelector('meta[name="ollama-web-i18n"]');
+ let messages = {};
+
+ if (!dataEl) {
+ console.error('i18n data element "ollama-web-i18n" was not found');
+ } else {
+ try {
+ const parsed = JSON.parse(dataEl.getAttribute("content") || "");
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new TypeError("i18n data must be a JSON object");
+ }
+ messages = parsed;
+ } catch (err) {
+ console.error("failed to parse i18n data", err);
+ }
+ }
+
function t(key, fallback) {
if (typeof key !== "string") {
return fallback !== undefined ? fallback : String(key);
}
const parts = key.split(".");
- let current = window.I18N;
+ let current = messages;
for (const part of parts) {
if (current == null || typeof current !== "object" || !(part in current)) {
return fallback !== undefined ? fallback : key;
@@ -24,5 +42,6 @@
return fallback !== undefined ? fallback : key;
}
+ window.I18N = messages;
window.t = t;
})();
diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html
index 11457e8..df877fa 100644
--- a/src/ollama_web/templates/index.html
+++ b/src/ollama_web/templates/index.html
@@ -11,9 +11,7 @@
<script src="{{ url_for('static', path='vendor/purify.min.js') }}"></script>
<script src="{{ url_for('static', path='vendor/highlight.min.js') }}"></script>
<script src="{{ url_for('static', path='vendor/katex.min.js') }}"></script>
- <script>
- window.I18N = {{ messages | tojson }};
- </script>
+ <meta name="ollama-web-i18n" content="{{ messages | tojson | forceescape }}" />
<script src="{{ url_for('static', path='i18n.js') }}"></script>
</head>
<body>
@@ -89,4 +87,4 @@
<script src="{{ url_for('static', path='mcp.js') }}"></script>
<script src="{{ url_for('static', path='app.js') }}"></script>
</body>
-</html> \ No newline at end of file
+</html>
diff --git a/src/ollama_web/templates/login.html b/src/ollama_web/templates/login.html
index 128c842..263afe7 100644
--- a/src/ollama_web/templates/login.html
+++ b/src/ollama_web/templates/login.html
@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ollama-web login</title>
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" />
- <script>
- window.I18N = {{ messages | tojson }};
- </script>
+ <meta name="ollama-web-i18n" content="{{ messages | tojson | forceescape }}" />
<script src="{{ url_for('static', path='i18n.js') }}"></script>
</head>
<body class="login-page">
diff --git a/tests/test_i18n_rendering.py b/tests/test_i18n_rendering.py
index 95015d5..0c37aad 100644
--- a/tests/test_i18n_rendering.py
+++ b/tests/test_i18n_rendering.py
@@ -2,7 +2,10 @@
from __future__ import annotations
+import json
+
import pytest
+from bs4 import BeautifulSoup
from starlette.testclient import TestClient
from ollama_web.app import create_app
@@ -30,6 +33,17 @@ def _render_index() -> str:
return response.text
+def _render_pages():
+ """Render login and authenticated index pages with their response headers."""
+ app = create_app()
+ with TestClient(app) as client:
+ login_page = client.get("/login")
+ response = client.post("/api/auth/login", json={"pin": settings.pin})
+ assert response.status_code == 200
+ index_page = client.get("/")
+ return login_page, index_page
+
+
def test_english_language_consistency(reset_language):
"""When settings.language='en', all UI text including MCP must be English."""
settings.language = "en"
@@ -70,3 +84,50 @@ def test_t_helper_respects_language_override():
assert t("common.send", lang="ja") == "送信"
assert t("mcp.server_settings", lang="en") == "MCP Server Settings"
assert t("mcp.server_settings", lang="ja") == "MCPサーバー設定"
+
+
+@pytest.mark.parametrize(
+ ("lang", "delete_text", "server_settings", "login_title"),
+ [
+ ("en", "Delete", "MCP Server Settings", "Login"),
+ ("ja", "削除", "MCPサーバー設定", "ログイン"),
+ ],
+)
+def test_browser_messages_are_embedded_as_json_data(
+ reset_language,
+ lang: str,
+ delete_text: str,
+ server_settings: str,
+ login_title: str,
+):
+ """Both pages expose parseable localized data without executable inline scripts."""
+ settings.language = lang
+ login_page, index_page = _render_pages()
+
+ for response in (login_page, index_page):
+ soup = BeautifulSoup(response.text, "html.parser")
+ data_element = soup.select_one('meta[name="ollama-web-i18n"]')
+ assert data_element is not None
+
+ messages = json.loads(data_element["content"])
+ assert messages["html"]["lang"] == lang
+ assert messages["common"]["delete"] == delete_text
+ assert messages["mcp"]["server_settings"] == server_settings
+ assert messages["login"]["title"] == login_title
+
+ inline_scripts = [script for script in soup.find_all("script") if not script.get("src")]
+ assert not inline_scripts
+
+
+def test_i18n_pages_keep_strict_script_csp(reset_language):
+ """The localization transport must not require weakening script-src."""
+ login_page, index_page = _render_pages()
+
+ for response in (login_page, index_page):
+ csp = response.headers["content-security-policy"]
+ script_directive = next(
+ directive.strip()
+ for directive in csp.split(";")
+ if directive.strip().startswith("script-src")
+ )
+ assert script_directive == "script-src 'self'"