deploy: route frontend and core through one origin
This commit is contained in:
@@ -1,15 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
backend_base_url=${BACKEND_BASE_URL-/api}
|
THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM:-http://core:8787}
|
||||||
if ! /usr/local/bin/validate-backend-url "$backend_base_url"; then
|
THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM%/}
|
||||||
echo "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment" >&2
|
case "$THT_FRONTEND_API_UPSTREAM" in
|
||||||
|
http://*|https://*) ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid THT_FRONTEND_API_UPSTREAM: use an internal http(s) upstream" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
export THT_FRONTEND_API_UPSTREAM
|
||||||
|
|
||||||
|
if ! envsubst '${THT_FRONTEND_API_UPSTREAM}' \
|
||||||
|
< /etc/nginx/templates/default.conf.template \
|
||||||
|
> /etc/nginx/conf.d/default.conf; then
|
||||||
|
echo "Unable to render nginx API upstream configuration" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
runtime_config=$(jq -cn --arg backend_base_url "$backend_base_url" \
|
|
||||||
'{backendBaseUrl: $backend_base_url}')
|
|
||||||
printf 'window.__THOTHII_CONFIG__ = %s;\n' "$runtime_config" \
|
|
||||||
> /usr/share/nginx/html/config.js
|
|
||||||
|
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# thothii-frontend: build Vite (React) + nginx-unprivileged (porta 8080).
|
# thothii-frontend: build Vite (React) + nginx-unprivileged (porta 8080).
|
||||||
# Build args:
|
|
||||||
# VITE_BASE prefisso asset ("/" standalone, "/datamart-builder/assets/" embedded)
|
|
||||||
# VITE_BACKEND_URL base API ("http://localhost:8787" standalone, "/datamart-builder/api" embedded)
|
|
||||||
FROM node:22-bookworm AS build
|
FROM node:22-bookworm AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
ARG VITE_BASE=/
|
ENV VITE_BASE=/ VITE_BACKEND_URL=/api
|
||||||
ARG VITE_BACKEND_URL=http://localhost:8787
|
|
||||||
ENV VITE_BASE=$VITE_BASE VITE_BACKEND_URL=$VITE_BACKEND_URL
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
# typecheck opzionale (non bloccante nella build dell'immagine)
|
|
||||||
RUN npx tsc -b 2>/dev/null || true
|
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
|
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
|
||||||
COPY --from=build /src/dist /usr/share/nginx/html
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
COPY --chmod=755 docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint
|
||||||
|
ENTRYPOINT ["/usr/local/bin/frontend-entrypoint"]
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -3,20 +3,16 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location = /config.js {
|
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /health {
|
location = /health {
|
||||||
proxy_pass http://core:8787/health;
|
proxy_pass ${THT_FRONTEND_API_UPSTREAM}/health;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://core:8787/;
|
# The trailing slash replaces the matched /api/ prefix before the private hop.
|
||||||
|
proxy_pass ${THT_FRONTEND_API_UPSTREAM}/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -27,7 +23,7 @@ server {
|
|||||||
proxy_set_header X-Authenticated-User $http_x_authenticated_user;
|
proxy_set_header X-Authenticated-User $http_x_authenticated_user;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 1h;
|
proxy_read_timeout 3600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
corpus=/etc/thothii/backend-url-cases.json
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
jq -c '.[]' "$corpus" | while IFS= read -r case_json; do
|
nginx_config=docker/nginx.conf.template
|
||||||
value=$(printf '%s' "$case_json" | jq -r '.value')
|
for setting in \
|
||||||
valid=$(printf '%s' "$case_json" | jq -r '.valid')
|
'proxy_pass ${THT_FRONTEND_API_UPSTREAM}/;' \
|
||||||
if BACKEND_BASE_URL="$value" /usr/local/bin/frontend-entrypoint true \
|
'proxy_http_version 1.1;' \
|
||||||
>/dev/null 2>&1; then
|
'proxy_buffering off;' \
|
||||||
actual=true
|
'proxy_read_timeout 3600s;'; do
|
||||||
else
|
if ! grep -Fq "$setting" "$nginx_config"; then
|
||||||
actual=false
|
echo "missing required nginx API/SSE setting: $setting" >&2
|
||||||
fi
|
|
||||||
if [ "$actual" != "$valid" ]; then
|
|
||||||
echo "entrypoint policy mismatch for BACKEND_BASE_URL=$value: expected $valid" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "frontend entrypoint canonical URL corpus: ok"
|
if ! grep -Fqx 'THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM:-http://core:8787}' \
|
||||||
|
docker/frontend-entrypoint.sh; then
|
||||||
|
echo "frontend entrypoint is missing the private core default" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "envsubst '\${THT_FRONTEND_API_UPSTREAM}'" docker/frontend-entrypoint.sh; then
|
||||||
|
echo "frontend entrypoint does not render the private upstream" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if rg -n 'BACKEND_BASE_URL|VITE_BACKEND_URL' docker/frontend-entrypoint.sh docker/nginx.conf.template; then
|
||||||
|
echo "frontend runtime routing still accepts a browser-facing backend URL" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "frontend same-origin proxy policy: ok"
|
||||||
|
|||||||
@@ -4,27 +4,5 @@ set -eu
|
|||||||
value=${1-}
|
value=${1-}
|
||||||
policy_file=${BACKEND_URL_POLICY_FILE:-/etc/thothii/backend-url-policy.json}
|
policy_file=${BACKEND_URL_POLICY_FILE:-/etc/thothii/backend-url-policy.json}
|
||||||
|
|
||||||
if jq -e --arg value "$value" '.relativeBases | index($value) != null' \
|
jq -e --arg value "$value" '.relativeBases | index($value) != null' \
|
||||||
"$policy_file" >/dev/null; then
|
"$policy_file" >/dev/null
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! jq -e --arg value "$value" \
|
|
||||||
'.absolutePattern as $pattern | $value | test($pattern)' \
|
|
||||||
"$policy_file" >/dev/null; then
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
authority=${value#*://}
|
|
||||||
authority=${authority%%/*}
|
|
||||||
port=""
|
|
||||||
case "$authority" in
|
|
||||||
*]:*) port=${authority##*:} ;;
|
|
||||||
*]) ;;
|
|
||||||
*:*) port=${authority##*:} ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -n "$port" ]; then
|
|
||||||
max_port=$(jq -r '.maxPort' "$policy_file")
|
|
||||||
if [ "${#port}" -gt 5 ] || [ "$port" -gt "$max_port" ]; then exit 2; fi
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
[
|
[
|
||||||
{ "value": "", "valid": true },
|
|
||||||
{ "value": "/", "valid": true },
|
|
||||||
{ "value": "/api", "valid": true },
|
{ "value": "/api", "valid": true },
|
||||||
{ "value": "/api/", "valid": true },
|
{ "value": "", "valid": false },
|
||||||
{ "value": "/datamart-builder/api", "valid": true },
|
{ "value": "/", "valid": false },
|
||||||
{ "value": "/datamart-builder/api/", "valid": true },
|
{ "value": "/api/", "valid": false },
|
||||||
{ "value": "http://localhost:8787", "valid": true },
|
{ "value": "/datamart-builder/api", "valid": false },
|
||||||
{ "value": "https://api.example.test/v1", "valid": true },
|
{ "value": "/datamart-builder/api/", "valid": false },
|
||||||
{ "value": "https://api.example.test/base/path/", "valid": true },
|
{ "value": "http://localhost:8787", "valid": false },
|
||||||
{ "value": "http://127.0.0.1:1/api", "valid": true },
|
{ "value": "https://api.example.test/v1", "valid": false },
|
||||||
{ "value": "http://[::1]:8787/api", "valid": true },
|
{ "value": "https://api.example.test/base/path/", "valid": false },
|
||||||
|
{ "value": "http://127.0.0.1:1/api", "valid": false },
|
||||||
|
{ "value": "http://[::1]:8787/api", "valid": false },
|
||||||
{ "value": "/backend", "valid": false },
|
{ "value": "/backend", "valid": false },
|
||||||
{ "value": "api", "valid": false },
|
{ "value": "api", "valid": false },
|
||||||
{ "value": "//evil.test", "valid": false },
|
{ "value": "//evil.test", "valid": false },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"relativeBases": ["", "/", "/api", "/api/", "/datamart-builder/api", "/datamart-builder/api/"],
|
"relativeBases": ["/api"],
|
||||||
"absolutePattern": "^https?://(?:\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(?::[0-9]+)?(?:/[^\\s?#]*)?/?$",
|
"absolutePattern": "^https?://(?:\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(?::[0-9]+)?(?:/[^\\s?#]*)?/?$",
|
||||||
"maxPort": 65535,
|
"maxPort": 65535,
|
||||||
"queryAllowed": false,
|
"queryAllowed": false,
|
||||||
|
|||||||
@@ -1,40 +1,26 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
|
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
|
||||||
import cases from "./backend-url-cases.json";
|
|
||||||
|
|
||||||
describe("resolveBackendUrl", () => {
|
describe("resolveBackendUrl", () => {
|
||||||
it("uses the runtime-injected backend URL", () => {
|
it("uses same-origin /api by default", () => {
|
||||||
expect(resolveBackendUrl({ backendBaseUrl: "/api" })).toBe("/api");
|
expect(resolveBackendUrl()).toBe("/api");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the Vite backend URL", () => {
|
it("does not allow a browser-facing backend override", () => {
|
||||||
expect(resolveBackendUrl(undefined)).toBe(import.meta.env.VITE_BACKEND_URL ?? "");
|
expect(() => resolveBackendUrl("https://api.example.test")).toThrow(/same-origin/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the client default when Vite has no configured backend", () => {
|
it("keeps the exported browser base on /api", () => {
|
||||||
expect(backendBaseUrl).toBe(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787");
|
expect(backendBaseUrl).toBe("/api");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["/backend", "api", "//evil.test", "ftp://example.test", "https://user:pass@example.test"])(
|
it.each(["/backend", "api", "//evil.test", "ftp://example.test", "https://user:pass@example.test"])(
|
||||||
"rejects unsupported backend URL %j",
|
"rejects any browser-facing backend URL %j",
|
||||||
(backendBaseUrl) => {
|
(backendBaseUrl) => {
|
||||||
expect(() => resolveBackendUrl({ backendBaseUrl })).toThrow(/BACKEND_BASE_URL/);
|
expect(() => resolveBackendUrl(backendBaseUrl)).toThrow(/same-origin/i);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(["", "/", "/api", "/api/", "http://localhost:8787", "https://api.example.test/v1"])(
|
|
||||||
"accepts supported backend URL %j",
|
|
||||||
(backendBaseUrl) => {
|
|
||||||
expect(resolveBackendUrl({ backendBaseUrl })).toBe(backendBaseUrl);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(cases)("applies the canonical policy to $value", ({ value, valid }) => {
|
|
||||||
const resolve = () => resolveBackendUrl({ backendBaseUrl: value });
|
|
||||||
if (valid) expect(resolve()).toBe(value);
|
|
||||||
else expect(resolve).toThrow(/BACKEND_BASE_URL/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("joinBackendPath", () => {
|
describe("joinBackendPath", () => {
|
||||||
|
|||||||
@@ -1,33 +1,6 @@
|
|||||||
import policy from "./backend-url-policy.json";
|
export function resolveBackendUrl(value?: string): string {
|
||||||
|
if (value === undefined || value === "/api") return "/api";
|
||||||
export interface RuntimeConfig {
|
throw new Error("Invalid backend URL: the browser must use the same-origin /api route");
|
||||||
backendBaseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__THOTHII_CONFIG__?: RuntimeConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveBackendUrl(config: RuntimeConfig | undefined): string {
|
|
||||||
const value = config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? "";
|
|
||||||
if (policy.relativeBases.includes(value)) return value;
|
|
||||||
try {
|
|
||||||
if (!new RegExp(policy.absolutePattern).test(value)) throw new Error("syntax");
|
|
||||||
const authority = value.replace(/^https?:\/\//, "").split("/", 1)[0];
|
|
||||||
const suffix = authority.startsWith("[")
|
|
||||||
? authority.slice(authority.indexOf("]") + 1)
|
|
||||||
: authority.slice(authority.lastIndexOf(":"));
|
|
||||||
const port = suffix.startsWith(":") ? suffix.slice(1) : "";
|
|
||||||
if (port && (port.length > 5 || Number(port) > policy.maxPort)) throw new Error("port");
|
|
||||||
return value;
|
|
||||||
} catch {
|
|
||||||
// Fall through to the single actionable runtime error below.
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinBackendPath(base: string, path: string): string {
|
export function joinBackendPath(base: string, path: string): string {
|
||||||
@@ -36,6 +9,4 @@ export function joinBackendPath(base: string, path: string): string {
|
|||||||
return `${normalizedBase}/${normalizedPath}`;
|
return `${normalizedBase}/${normalizedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const backendBaseUrl =
|
export const backendBaseUrl = resolveBackendUrl();
|
||||||
resolveBackendUrl(typeof window === "undefined" ? undefined : window.__THOTHII_CONFIG__) ||
|
|
||||||
"http://localhost:8787";
|
|
||||||
|
|||||||
@@ -6,6 +6,40 @@ cd "$(dirname "$0")/.."
|
|||||||
policy=frontend/src/api/backend-url-policy.json
|
policy=frontend/src/api/backend-url-policy.json
|
||||||
corpus=frontend/src/api/backend-url-cases.json
|
corpus=frontend/src/api/backend-url-cases.json
|
||||||
|
|
||||||
|
assert_policy() {
|
||||||
|
value=$1
|
||||||
|
expected=$2
|
||||||
|
if BACKEND_URL_POLICY_FILE="$policy" ./docker/validate-backend-url.sh "$value"; then
|
||||||
|
actual=true
|
||||||
|
else
|
||||||
|
actual=false
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
echo "browser URL policy mismatch for $value: expected $expected" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_policy /api true
|
||||||
|
assert_policy /datamart-builder/api false
|
||||||
|
assert_policy http://localhost:8787 false
|
||||||
|
assert_policy https://api.example.test/v1 false
|
||||||
|
|
||||||
|
if rg -n '^ARG VITE_(BASE|BACKEND_URL)' docker/frontend.Dockerfile; then
|
||||||
|
echo "frontend image must not expose deployment-specific Vite build arguments" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! rg -Fx 'ENV VITE_BASE=/ VITE_BACKEND_URL=/api' docker/frontend.Dockerfile >/dev/null; then
|
||||||
|
echo "frontend image must build the fixed / assets and /api browser contract" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if rg -n 'datamart-builder' frontend/src/api/runtime-config.ts frontend/src/api/backend-url-policy.json docker/nginx.conf.template docker/frontend-entrypoint.sh docker/frontend.Dockerfile; then
|
||||||
|
echo "active frontend routing still assumes a portal prefix" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
jq -c '.[]' "$corpus" | while IFS= read -r case_json; do
|
jq -c '.[]' "$corpus" | while IFS= read -r case_json; do
|
||||||
value=$(printf '%s' "$case_json" | jq -r '.value')
|
value=$(printf '%s' "$case_json" | jq -r '.value')
|
||||||
valid=$(printf '%s' "$case_json" | jq -r '.valid')
|
valid=$(printf '%s' "$case_json" | jq -r '.valid')
|
||||||
|
|||||||
Reference in New Issue
Block a user