Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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;"]
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions docker/40-drawdb-runtime-config.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<body theme-mode="light">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions public/runtime-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
globalThis.__DRAWDB_RUNTIME_CONFIG__ = Object.freeze({
VITE_BACKEND_URL: "",
VITE_GIST_BACKEND_URL: "",
});
3 changes: 2 additions & 1 deletion src/api/email.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 2 additions & 4 deletions src/api/gists.js
Original file line number Diff line number Diff line change
@@ -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`, {
Expand Down
67 changes: 67 additions & 0 deletions src/config/runtime.js
Original file line number Diff line number Diff line change
@@ -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,
);
}
51 changes: 51 additions & 0 deletions test/runtime-config-entrypoint.test.js
Original file line number Diff line number Diff line change
@@ -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='"</script>&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));
});
86 changes: 86 additions & 0 deletions test/runtime-config.test.js
Original file line number Diff line number Diff line change
@@ -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='\"<script>";

assert.equal(
resolveBackendUrl(
{ VITE_BACKEND_URL: encode(runtimeUrl) },
{ VITE_BACKEND_URL: "https://build.example.com" },
),
runtimeUrl,
);
});

test("backend URLs have trailing slashes removed", () => {
assert.equal(
resolveBackendUrl(
{ VITE_BACKEND_URL: encode("https://runtime.example.com///") },
{},
),
"https://runtime.example.com",
);
assert.equal(
resolveGistBackendUrl(
{},
{
VITE_GIST_BACKEND_URL: "https://build.example.com/",
},
),
"https://build.example.com",
);
});

test("gist URL uses runtime settings in specificity order", () => {
const runtimeConfig = {
VITE_BACKEND_URL: encode("https://runtime.example.com"),
VITE_GIST_BACKEND_URL: encode("https://gists.example.com"),
};

assert.equal(
resolveGistBackendUrl(runtimeConfig, {
VITE_GIST_BACKEND_URL: "https://build-gists.example.com",
}),
"https://gists.example.com",
);
});

test("runtime backend URL overrides build-time gist settings", () => {
assert.equal(
resolveGistBackendUrl(
{ VITE_BACKEND_URL: encode("https://runtime.example.com") },
{ VITE_GIST_BACKEND_URL: "https://build-gists.example.com" },
),
"https://runtime.example.com",
);
});

test("build-time and local defaults remain available", () => {
assert.equal(
resolveBackendUrl({}, { VITE_BACKEND_URL: "https://build.example.com" }),
"https://build.example.com",
);
assert.equal(resolveBackendUrl({}, {}), undefined);
assert.equal(resolveGistBackendUrl({}, {}), "http://localhost:5000");
});

test("malformed runtime values fall back to build-time settings", () => {
for (const malformedValue of ["not valid base64", "/w==", "AB=="]) {
assert.equal(
resolveBackendUrl(
{ VITE_BACKEND_URL: malformedValue },
{ VITE_BACKEND_URL: "https://build.example.com" },
),
"https://build.example.com",
);
}
});
Loading