diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2fe5b206..851974075 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,8 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm install + - name: Run tests + run: npm test - name: Run eslint run: npm run lint - name: Run vite build diff --git a/Dockerfile b/Dockerfile index 1f87fd391..d30539fee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN npm run build # Stage 2: Setup the Nginx Server to serve the app FROM docker.io/library/nginx:stable-alpine3.23 AS production COPY --from=build /app/dist /usr/share/nginx/html -RUN echo 'server { listen 80; server_name _; root /usr/share/nginx/html; location / { try_files $uri /index.html; } }' > /etc/nginx/conf.d/default.conf +COPY docker/40-drawdb-runtime-config.sh /docker-entrypoint.d/ +RUN chmod +x /docker-entrypoint.d/40-drawdb-runtime-config.sh +RUN echo 'server { listen 80; server_name _; root /usr/share/nginx/html; location = /runtime-config.js { add_header Cache-Control "no-store"; } location / { try_files $uri /index.html; } }' > /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 61ce89ecb..1a9123f82 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,18 @@ docker run -p 3000:80 drawdb If you want to enable sharing, set up the [server](https://github.com/drawdb-io/drawdb-server) and environment variables according to `.env.sample`. This is optional unless you need to share files. +The prebuilt Docker image reads backend URLs when the container starts, so it +does not need to be rebuilt for each environment: + +```bash +docker run -p 3000:80 \ + -e VITE_BACKEND_URL=https://drawdb-server.example.com \ + ghcr.io/drawdb-io/drawdb:latest +``` + +Set `VITE_GIST_BACKEND_URL` as well to route sharing requests to a different +backend. + ## Contributing Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project. diff --git a/docker/40-drawdb-runtime-config.sh b/docker/40-drawdb-runtime-config.sh new file mode 100644 index 000000000..35841242c --- /dev/null +++ b/docker/40-drawdb-runtime-config.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +encode() { + printf '%s' "$1" | base64 | tr -d '\r\n' +} + +backend_url="$(encode "${VITE_BACKEND_URL:-}")" +gist_backend_url="$(encode "${VITE_GIST_BACKEND_URL:-}")" +output_path="${1:-/usr/share/nginx/html/runtime-config.js}" + +write_config() { + printf '%s\n' \ + 'globalThis.__DRAWDB_RUNTIME_CONFIG__ = Object.freeze({' \ + " VITE_BACKEND_URL: \"${backend_url}\"," \ + " VITE_GIST_BACKEND_URL: \"${gist_backend_url}\"," \ + '});' +} + +if [ "${output_path}" = '-' ]; then + write_config +else + write_config > "${output_path}" +fi diff --git a/index.html b/index.html index dd19a675f..6f575f60f 100644 --- a/index.html +++ b/index.html @@ -55,6 +55,7 @@
+ diff --git a/package.json b/package.json index 848104764..9cbfaa074 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "test": "node --test test/runtime-config.test.js test/runtime-config-entrypoint.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/public/runtime-config.js b/public/runtime-config.js new file mode 100644 index 000000000..5f132eae8 --- /dev/null +++ b/public/runtime-config.js @@ -0,0 +1,4 @@ +globalThis.__DRAWDB_RUNTIME_CONFIG__ = Object.freeze({ + VITE_BACKEND_URL: "", + VITE_GIST_BACKEND_URL: "", +}); diff --git a/src/api/email.js b/src/api/email.js index e0375c26c..a3ad31ec2 100644 --- a/src/api/email.js +++ b/src/api/email.js @@ -1,7 +1,8 @@ import axios from "axios"; +import { getBackendUrl } from "../config/runtime.js"; export async function send(subject, message, attachments) { - return await axios.post(`${import.meta.env.VITE_BACKEND_URL}/email/send`, { + return await axios.post(`${getBackendUrl()}/email/send`, { subject, message, attachments, diff --git a/src/api/gists.js b/src/api/gists.js index 7f05bcf4b..c5fb55cc2 100644 --- a/src/api/gists.js +++ b/src/api/gists.js @@ -1,14 +1,12 @@ import axios from "axios"; +import { getGistBackendUrl } from "../config/runtime.js"; export const SHARE_FILENAME = "share.json"; export const VERSION_FILENAME = "versionned.json"; const description = "drawDB diagram"; -const baseUrl = - import.meta.env.VITE_GIST_BACKEND_URL || - import.meta.env.VITE_BACKEND_URL || - "http://localhost:5000"; +const baseUrl = getGistBackendUrl(); export async function create(filename, content) { const res = await axios.post(`${baseUrl}/gists`, { diff --git a/src/config/runtime.js b/src/config/runtime.js new file mode 100644 index 000000000..17896253f --- /dev/null +++ b/src/config/runtime.js @@ -0,0 +1,67 @@ +const DEFAULT_BACKEND_URL = "http://localhost:5000"; + +function decodeRuntimeValue(value) { + if ( + typeof value !== "string" || + value.length === 0 || + value.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + return undefined; + } + + try { + const decoded = atob(value); + if (btoa(decoded) !== value) { + return undefined; + } + + const bytes = Uint8Array.from(decoded, (character) => + character.charCodeAt(0), + ); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function getRuntimeValue(runtimeConfig, key) { + return decodeRuntimeValue(runtimeConfig?.[key]); +} + +function normalizeBackendUrl(value) { + return value?.replace(/\/+$/, ""); +} + +export function resolveBackendUrl(runtimeConfig, buildConfig) { + return normalizeBackendUrl( + getRuntimeValue(runtimeConfig, "VITE_BACKEND_URL") || + buildConfig?.VITE_BACKEND_URL, + ); +} + +export function resolveGistBackendUrl(runtimeConfig, buildConfig) { + return normalizeBackendUrl( + getRuntimeValue(runtimeConfig, "VITE_GIST_BACKEND_URL") || + getRuntimeValue(runtimeConfig, "VITE_BACKEND_URL") || + buildConfig?.VITE_GIST_BACKEND_URL || + buildConfig?.VITE_BACKEND_URL || + DEFAULT_BACKEND_URL, + ); +} + +export function getBackendUrl() { + return resolveBackendUrl( + globalThis.__DRAWDB_RUNTIME_CONFIG__, + import.meta.env, + ); +} + +export function getGistBackendUrl() { + return resolveGistBackendUrl( + globalThis.__DRAWDB_RUNTIME_CONFIG__, + import.meta.env, + ); +} diff --git a/test/runtime-config-entrypoint.test.js b/test/runtime-config-entrypoint.test.js new file mode 100644 index 000000000..0a92b8b14 --- /dev/null +++ b/test/runtime-config-entrypoint.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import test from "node:test"; +import vm from "node:vm"; + +function getShell() { + if (process.platform !== "win32") { + return "sh"; + } + + const gitBash = path.join( + process.env.ProgramFiles || "C:\\Program Files", + "Git", + "bin", + "bash.exe", + ); + return existsSync(gitBash) ? gitBash : "sh"; +} + +test("the Docker entrypoint safely serializes runtime URLs", () => { + const backendUrl = `https://服务.example.com/api?value='"&line= +second`; + const gistBackendUrl = "https://gists.example.com/path?x=1&y=2"; + const result = spawnSync( + getShell(), + ["docker/40-drawdb-runtime-config.sh", "-"], + { + encoding: "utf8", + env: { + ...process.env, + VITE_BACKEND_URL: backendUrl, + VITE_GIST_BACKEND_URL: gistBackendUrl, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + + const context = {}; + vm.runInNewContext(result.stdout, context); + const config = context.__DRAWDB_RUNTIME_CONFIG__; + const decode = (value) => Buffer.from(value, "base64").toString("utf8"); + + assert.equal(decode(config.VITE_BACKEND_URL), backendUrl); + assert.equal(decode(config.VITE_GIST_BACKEND_URL), gistBackendUrl); + assert.ok(Object.isFrozen(config)); +}); diff --git a/test/runtime-config.test.js b/test/runtime-config.test.js new file mode 100644 index 000000000..20b112e1f --- /dev/null +++ b/test/runtime-config.test.js @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import test from "node:test"; + +import { + resolveBackendUrl, + resolveGistBackendUrl, +} from "../src/config/runtime.js"; + +const encode = (value) => Buffer.from(value, "utf8").toString("base64"); + +test("runtime backend URL overrides the build-time URL", () => { + const runtimeUrl = "https://服务.example.com/api?value='\"