From 0b1d647aecd75ee49faea823f8d73e95e788e943 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:24:45 +0100 Subject: [PATCH] feat(#14): Add zero-setup Docker data initialization --- README.md | 97 +++++++++++-- docker-compose.yml | 1 + package.json | 3 +- screenshot7.png | Bin 0 -> 37989 bytes screenshot8.png | Bin 0 -> 57426 bytes server.mjs | 19 ++- src/app.d.ts | 8 ++ src/lib/components/FirstRunCard.svelte | 14 +- src/lib/components/NetworkDiagram.svelte | 113 +++++++++------ src/lib/server/networkPersistence.ts | 10 ++ src/lib/shared/networkPersistenceCore.mjs | 97 ++++++++++++- src/routes/api/network-data/+server.ts | 51 ++++++- tests/networkPersistenceCore.test.mjs | 163 ++++++++++++++++++++++ vite.config.ts | 5 + 14 files changed, 509 insertions(+), 72 deletions(-) create mode 100644 screenshot7.png create mode 100644 screenshot8.png create mode 100644 tests/networkPersistenceCore.test.mjs diff --git a/README.md b/README.md index ef7c8ce..ae1c8e7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.** -Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can version, back up, and reuse. +Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can back up, reuse, and optionally version after reviewing it for sensitive information. - Homelab-friendly: run it in minutes with Docker. - Practical: edit in the UI and autosave to `network.json`. @@ -47,11 +47,10 @@ Open Network Diagram helps you document your infrastructure in a visual UI while This is the fastest way to run Open Network Diagram for a home lab. -1. Create a local data folder and seed your first `network.json`: +1. Create a local data folder: ```bash -mkdir -p ond-data -curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example -o ond-data/network.json +mkdir -p data ``` 2. Run the published Docker image: @@ -61,15 +60,18 @@ docker run -d \ --name open-network-diagram \ --restart unless-stopped \ -p 8080:3000 \ + --user "$(id -u):$(id -g)" \ -e NETWORK_DATA_FILE=/app/data/network.json \ -e NETWORK_BACKUP_DIR=/app/data/.backups \ - -v "$(pwd)/ond-data:/app/data" \ + -v "$(pwd)/data:/app/data" \ jcreek23/open-network-diagram:latest ``` +On first access, Open Network Diagram creates a valid blank `data/network.json` and shows the getting-started card. + 3. Open the app at `http://localhost:8080`. -4. Edit your topology in the UI. Changes persist to `ond-data/network.json`. +4. Add your first machine. Changes and rolling backups remain directly accessible in the local `data` directory across container updates and restarts. Useful follow-up commands: @@ -79,6 +81,29 @@ docker stop open-network-diagram docker rm open-network-diagram ``` +The `--user` option runs the container with your host UID and GID so files in the bind mount remain writable and owned by your account. Docker Desktop commonly handles bind-mount permissions without this mapping, so macOS users can omit `--user` if their Docker Desktop configuration requires the image's built-in user. If storage is not writable, the app leaves it untouched, loads the bundled read-only demo, and reports the exact permission problem in the UI. + +### Optional Sample Data + +The application starts with a blank network by default. To explore the sample topology instead, download it before starting the container: + +```bash +curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example \ + -o data/network.json +``` + +The sample is optional and is never required for startup. + +## Deployment Security + +Open Network Diagram does not include authentication or TLS. The quick-start port mapping `-p 8080:3000` listens on all host interfaces, so any device that can reach the host can view the topology and, in writable mode, change it. + +Do not expose a writable deployment directly to the internet. Restrict access with a firewall, LAN or VPN, or place the application behind an authenticated reverse proxy that terminates TLS. + +To make the service reachable only from the Docker host, replace the quick-start mapping with `-p 127.0.0.1:8080:3000`. + +For a public demonstration, add `-e NETWORK_READ_ONLY=true` to `docker run`, or add `NETWORK_READ_ONLY: 'true'` under Compose's `environment`. Read-only mode prevents changes and uses the bundled demo if the configured data file is missing, but it does not hide a configured topology or replace authentication. + ## What It Looks Like | Network view with ethernet labels | Hosts & VMs view with VMs expanded | Modal editing a machine | @@ -89,6 +114,10 @@ docker rm open-network-diagram | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- | | ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) | ![Open Network Diagram dark mode](screenshot6.png) | +| Zero-setup first run | Read-only live demo | +| ------------------------------------------------------------------------ | ------------------------------------------------------------ | +| ![Open Network Diagram zero-setup first-run onboarding](screenshot7.png) | ![Open Network Diagram read-only live demo](screenshot8.png) | + ## Docker Compose Option If you prefer compose: @@ -97,10 +126,11 @@ If you prefer compose: services: open-network-diagram: image: jcreek23/open-network-diagram:latest + user: '${OND_UID:-1000}:${OND_GID:-1000}' ports: - '8080:3000' volumes: - - ./ond-data:/app/data + - ./data:/app/data environment: NETWORK_DATA_FILE: /app/data/network.json NETWORK_BACKUP_DIR: /app/data/.backups @@ -110,9 +140,40 @@ services: Start it with: ```bash -docker compose up -d +mkdir -p data +OND_UID="$(id -u)" OND_GID="$(id -g)" docker compose up -d ``` +`OND_UID` and `OND_GID` are used only by Compose to select the container process identity; they are not application environment variables. The defaults suit common Linux installations. Docker Desktop users can remove the `user` line if their file-sharing configuration requires the image's built-in user. + +### Using a Docker Named Volume + +If host-directory permissions are inconvenient, Docker can manage the storage volume instead: + +```bash +docker run -d \ + --name open-network-diagram \ + --restart unless-stopped \ + -p 8080:3000 \ + -e NETWORK_DATA_FILE=/app/data/network.json \ + -e NETWORK_BACKUP_DIR=/app/data/.backups \ + -v ond-data:/app/data \ + jcreek23/open-network-diagram:latest +``` + +Docker creates the `ond-data` volume automatically. Use `docker volume inspect ond-data` to inspect its location or `docker cp open-network-diagram:/app/data/network.json ./network.json` to copy the data file out. + +### Versioning Your Topology + +`data/network.json` and `data/.backups` are ignored by Git because topology data may expose host names, addresses, MAC addresses, and other sensitive infrastructure details. If you have reviewed and sanitized the data and deliberately want to track it, opt in with: + +```bash +git add -f data/network.json +git commit -m "Track network topology" +``` + +Once the file is tracked, normal Git commands include later changes. Backup files remain ignored. + ## For Developers ### Local Development @@ -139,6 +200,8 @@ pnpm run icons:manifest # regenerate local vendor icon manifest - API endpoint: `GET/PUT /api/network-data` - Writes are enabled unless `NETWORK_READ_ONLY=true` +- Writable deployments automatically initialize a missing or whitespace-only data file with a blank network. +- Read-only deployments never initialize or modify the configured data file and continue to use the bundled demo when the API data is unavailable. - When writes are unavailable, API responses include `writableReason` for diagnostics. - Writes are persisted atomically to the configured data file - Rolling backups are kept in the backup directory (last 5) @@ -232,7 +295,7 @@ OpenNetworkDiagram/ ├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog ├── static/data/network.json # Demo dataset (Netlify) ├── static/icons/vendor/ # Vendored icon assets (runtime-local) -├── data/network.json.example # Starter data template for Docker users +├── data/network.json.example # Optional starter data template ├── third_party/ # Third-party provenance + licensing ├── Dockerfile # Docker build/runtime image ├── server.mjs # Node runtime server (static + API) @@ -252,9 +315,19 @@ OpenNetworkDiagram/ 1. Fork the repository. 2. Create a feature branch. -3. Commit your changes. -4. Push your branch. -5. Open a pull request. +3. Before committing, run: + +```bash +pnpm lint +pnpm test +pnpm check +pnpm run build:docker +pnpm run build:netlify +``` + +4. Commit your changes. +5. Push your branch. +6. Open a pull request. ## License diff --git a/docker-compose.yml b/docker-compose.yml index 2400de4..c94d90b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: context: . dockerfile: Dockerfile image: open-network-diagram:local + user: '${OND_UID:-1000}:${OND_GID:-1000}' ports: - '8080:3000' volumes: diff --git a/package.json b/package.json index 5050675..d3e69bd 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "dev": "vite dev", "build": "vite build", "build:docker": "DEPLOY_TARGET=docker vite build", - "build:netlify": "NETWORK_READ_ONLY=true DEPLOY_TARGET=netlify vite build", + "build:netlify": "NETWORK_READ_ONLY=true OND_STATIC_DEMO=true DEPLOY_TARGET=netlify vite build", "start": "node server.mjs", "preview": "vite preview", + "test": "node --test tests/*.test.mjs", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/screenshot7.png b/screenshot7.png new file mode 100644 index 0000000000000000000000000000000000000000..7ded9f36086c9fcd8c360130a36c90f243a27c49 GIT binary patch literal 37989 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%0DG^TyWyrcr|H9KlMj(7re1!EYOBX-42 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 literal 0 HcmV?d00001 diff --git a/screenshot8.png b/screenshot8.png new file mode 100644 index 0000000000000000000000000000000000000000..ae671281c496a55f7a4f7f3eb58f2f55384cbb4d GIT binary patch literal 57426 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

& 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 ({ + writable: false, + reason: null + })); sendJson(response, 500, { - error: error instanceof Error ? error.message : 'Failed to read network data' + error: error instanceof Error ? error.message : 'Failed to read network data', + writable: false, + writableReason: writableState.reason }); } return; diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..5cbf5a0 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,14 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { + interface ImportMetaEnv { + readonly OND_STATIC_DEMO: boolean; + } + + interface ImportMeta { + readonly env: ImportMetaEnv; + } + namespace App { // interface Error {} // interface Locals {} diff --git a/src/lib/components/FirstRunCard.svelte b/src/lib/components/FirstRunCard.svelte index bdd22e9..2654571 100644 --- a/src/lib/components/FirstRunCard.svelte +++ b/src/lib/components/FirstRunCard.svelte @@ -4,6 +4,7 @@ export let isSampleData = false; export let writable = false; export let hasMachines = false; + export let storageReady = false; const dispatch = createEventDispatcher<{ addmachine: void; @@ -15,7 +16,11 @@

Map your network

- {#if isSampleData} + {#if storageReady} +

+ Your network data file is ready. Add your first machine to start mapping. +

+ {:else if isSampleData}

This is sample data — replace it with your own machines.

{/if}
@@ -82,6 +87,13 @@ color: var(--status-warn); } + .storage-note { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-2); + } + .actions { display: flex; flex-direction: column; diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index b8844a4..f13df0e 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -57,6 +57,8 @@ | null; const autosaveDelayMs = 800; + const staticDemoMode = import.meta.env.OND_STATIC_DEMO; + const staticDemoReason = 'Static demo deployment; changes are not persisted.'; let container: HTMLDivElement; let diagramStage: HTMLDivElement; @@ -299,13 +301,19 @@ let subnetEditorOpen = false; let showRackManager = false; - // First-run card: shown for an empty dataset or the untouched example - // file, until dismissed (persisted) or the user makes their first edit. + // Writable empty datasets use a session-only dismissal so onboarding + // returns after reload until the user adds a real entity. Sample-data + // dismissal remains persisted for returning demo users. const firstRunDismissKey = 'ond-firstrun-dismissed'; let firstRunDismissed = typeof localStorage !== 'undefined' && localStorage.getItem(firstRunDismissKey) === '1'; + let emptyFirstRunDismissedThisSession = false; function dismissFirstRun() { + if (writable && totalEntities === 0) { + emptyFirstRunDismissedThisSession = true; + return; + } firstRunDismissed = true; try { localStorage.setItem(firstRunDismissKey, '1'); @@ -329,9 +337,9 @@ ].every((name) => name.trim().toLowerCase().startsWith('example')); $: showFirstRun = hasLoadedInitialData && - !firstRunDismissed && !isLoadingData && - (totalEntities === 0 || (totalEntities <= 4 && allExampleNames)); + ((writable && totalEntities === 0 && !emptyFirstRunDismissedThisSession) || + (!firstRunDismissed && totalEntities <= 4 && allExampleNames)); let copiedIp: string | null = null; let copiedIpTimer: ReturnType | null = null; let pendingSubnetRemoval: number | null = null; @@ -695,6 +703,13 @@ return `Read-only: ${reason}`; } + function resolveStaticFallbackNotice(reason: string | null): string { + if (!reason) { + return 'Read-only: API unavailable; using bundled static data.'; + } + return `Read-only: ${reason} Using bundled static data.`; + } + // Nodes are invisible hit targets (interaction, layout, edge anchoring); // the visible cards live in NodeCardLayer. Only edges are canvas-drawn, // styled from the same CSS tokens as the chrome. @@ -1257,6 +1272,7 @@ return; } firstRunDismissed = true; + emptyFirstRunDismissedThisSession = true; saveError = null; saveState = 'unsaved'; if (!writable) { @@ -1898,46 +1914,56 @@ isLoadingData = true; loadError = null; saveError = null; + let apiFallbackReason: string | null = staticDemoMode ? staticDemoReason : null; - try { - const response = await fetch('/api/network-data', { - headers: { Accept: 'application/json' } - }); - if (!response.ok) { - throw new Error(`API responded with ${response.status}`); - } - const body = (await response.json()) as { - data: NetworkData; - writable: boolean; - writableReason?: string | null; - source: string; - updatedAt: string; - }; - const validation = validateNetworkData(body.data); - if (!validation.valid || !validation.data) { - throw new Error('API returned invalid network data'); - } + if (!staticDemoMode) { + try { + const response = await fetch('/api/network-data', { + headers: { Accept: 'application/json' } + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + writableReason?: string | null; + }; + apiFallbackReason = + body.writableReason ?? body.error ?? `API responded with ${response.status}`; + throw new Error(apiFallbackReason); + } + const body = (await response.json()) as { + data: NetworkData; + writable: boolean; + writableReason?: string | null; + source: string; + updatedAt: string; + }; + const validation = validateNetworkData(body.data); + if (!validation.valid || !validation.data) { + throw new Error('API returned invalid network data'); + } - networkData = cloneNetworkData(validation.data); - ensureSelectedTargetValid(); - writable = body.writable; - readOnlyNotice = body.writable - ? defaultReadOnlyNotice - : resolveReadOnlyNotice(body.writableReason); - dataSourceLabel = body.source; - lastSavedSnapshot = JSON.stringify(networkData); - saveState = 'saved'; - connectionDraftByKey = {}; - revalidateDraft(); - refreshGraph(); - hasLoadedInitialData = true; - isLoadingData = false; - return; - } catch (apiError) { - console.warn( - '[OpenNetworkDiagram] API load unavailable, falling back to static JSON', - apiError - ); + networkData = cloneNetworkData(validation.data); + ensureSelectedTargetValid(); + writable = body.writable; + readOnlyNotice = body.writable + ? defaultReadOnlyNotice + : resolveReadOnlyNotice(body.writableReason); + dataSourceLabel = body.source; + lastSavedSnapshot = JSON.stringify(networkData); + saveState = 'saved'; + connectionDraftByKey = {}; + revalidateDraft(); + refreshGraph(); + hasLoadedInitialData = true; + isLoadingData = false; + return; + } catch (apiError) { + apiFallbackReason ??= resolveErrorMessage(apiError); + console.warn( + '[OpenNetworkDiagram] API load unavailable, falling back to static JSON', + apiError + ); + } } try { @@ -1945,7 +1971,7 @@ networkData = cloneNetworkData(fallbackData); ensureSelectedTargetValid(); writable = false; - readOnlyNotice = 'Read-only: API unavailable; using bundled static data.'; + readOnlyNotice = resolveStaticFallbackNotice(apiFallbackReason); dataSourceLabel = jsonPath; lastSavedSnapshot = JSON.stringify(networkData); saveState = 'saved'; @@ -2501,6 +2527,7 @@ isSampleData={allExampleNames} {writable} hasMachines={networkData.machines.length > 0} + storageReady={writable && totalEntities === 0} on:addmachine={addMachine} on:connectport={openFirstMachineEditor} on:openipam={() => (showIpamPanel = true)} diff --git a/src/lib/server/networkPersistence.ts b/src/lib/server/networkPersistence.ts index 66e8164..27a598c 100644 --- a/src/lib/server/networkPersistence.ts +++ b/src/lib/server/networkPersistence.ts @@ -2,6 +2,7 @@ import { checkWritableState as checkWritableStateCore, getWritableState as getWritableStateCore, isWriteEnabled as isWriteEnabledCore, + readOrInitializeNetworkFile as readOrInitializeNetworkFileCore, readNetworkFile as readNetworkFileCore, writeNetworkFile as writeNetworkFileCore } from '../shared/networkPersistenceCore.mjs'; @@ -18,6 +19,11 @@ export interface WritableState { reason: string | null; } +export interface NetworkFileBootstrapResult extends NetworkFileMetadata { + data: NetworkData; + initialized: boolean; +} + export function isWriteEnabled(): boolean { return isWriteEnabledCore(); } @@ -26,6 +32,10 @@ export async function readNetworkFile(): Promise<{ data: NetworkData } & Network return (await readNetworkFileCore()) as { data: NetworkData } & NetworkFileMetadata; } +export async function readOrInitializeNetworkFile(): Promise { + return (await readOrInitializeNetworkFileCore()) as NetworkFileBootstrapResult; +} + export async function writeNetworkFile(data: NetworkData): Promise { return (await writeNetworkFileCore(data)) as NetworkFileMetadata; } diff --git a/src/lib/shared/networkPersistenceCore.mjs b/src/lib/shared/networkPersistenceCore.mjs index 4854508..2e8d7a2 100644 --- a/src/lib/shared/networkPersistenceCore.mjs +++ b/src/lib/shared/networkPersistenceCore.mjs @@ -16,12 +16,19 @@ import path from 'node:path'; * @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData * @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata * @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult + * @typedef {{ status: 'ready'; data: NetworkData; source: string; updatedAt: string } | { status: 'missing' | 'blank'; source: string; updatedAt: string | null }} NetworkFileReadState + * @typedef {NetworkFileReadResult & { initialized: boolean }} NetworkFileBootstrapResult * @typedef {{ writable: boolean; reason: string | null }} WritableState + * @typedef {{ createBackup?: boolean }} WriteNetworkFileOptions */ const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json'); const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups'); +const EMPTY_NETWORK_DATA = Object.freeze({ + machines: Object.freeze([]), + devices: Object.freeze([]) +}); /** * @returns {string} @@ -86,20 +93,57 @@ async function readUpdatedAt(dataFilePath) { } /** - * @returns {Promise} + * @returns {Promise} */ -export async function readNetworkFile() { +export async function readNetworkFileState() { const source = resolveDataFilePath(); - const raw = await readFile(source, 'utf8'); + let raw; + try { + raw = await readFile(source, 'utf8'); + } catch (error) { + if (formatFileSystemErrorCode(error) === 'ENOENT') { + return { + status: 'missing', + source, + updatedAt: null + }; + } + throw error; + } + + if (raw.trim().length === 0) { + return { + status: 'blank', + source, + updatedAt: await readUpdatedAt(source) + }; + } + const parsed = JSON.parse(raw); const updatedAt = await readUpdatedAt(source); return { + status: 'ready', data: parsed, source, updatedAt }; } +/** + * @returns {Promise} + */ +export async function readNetworkFile() { + const state = await readNetworkFileState(); + if (state.status !== 'ready') { + throw new Error(`Network data file "${state.source}" is ${state.status}.`); + } + return { + data: state.data, + source: state.source, + updatedAt: state.updatedAt + }; +} + /** * @param {string} dataFilePath * @param {string} backupDirectory @@ -149,14 +193,21 @@ export async function trimBackups(backupDirectory, baseName, keep = 5) { /** * @param {NetworkData} data + * @param {WriteNetworkFileOptions} [options] * @returns {Promise} */ -export async function writeNetworkFile(data) { +export async function writeNetworkFile(data, options) { + if (!isWriteEnabled()) { + throw new Error('Writes disabled by NETWORK_READ_ONLY=true.'); + } + const source = resolveDataFilePath(); const backupDirectory = resolveBackupDirectory(); const directory = path.dirname(source); await mkdir(directory, { recursive: true }); - await createBackupIfPresent(source, backupDirectory); + if (options?.createBackup ?? true) { + await createBackupIfPresent(source, backupDirectory); + } const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`; const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`; @@ -244,3 +295,39 @@ export async function getWritableState() { export async function checkWritableState() { return (await getWritableState()).writable; } + +/** + * Reads configured network data, creating a valid blank dataset only when the + * file is missing/blank and the deployment has confirmed writable storage. + * + * @returns {Promise} + */ +export async function readOrInitializeNetworkFile() { + const state = await readNetworkFileState(); + if (state.status === 'ready') { + return { + data: state.data, + source: state.source, + updatedAt: state.updatedAt, + initialized: false + }; + } + + const writableState = await getWritableState(); + if (!writableState.writable) { + const suffix = writableState.reason ? ` ${writableState.reason}` : ''; + throw new Error(`Network data file "${state.source}" is ${state.status}.${suffix}`); + } + + const data = { + machines: [...EMPTY_NETWORK_DATA.machines], + devices: [...EMPTY_NETWORK_DATA.devices] + }; + const metadata = await writeNetworkFile(data, { createBackup: false }); + return { + data, + source: metadata.source, + updatedAt: metadata.updatedAt, + initialized: true + }; +} diff --git a/src/routes/api/network-data/+server.ts b/src/routes/api/network-data/+server.ts index 2a487dc..7a295fc 100644 --- a/src/routes/api/network-data/+server.ts +++ b/src/routes/api/network-data/+server.ts @@ -3,11 +3,14 @@ import { json } from '@sveltejs/kit'; import { validateNetworkData } from '$lib/data/networkSchema'; import { getWritableState, - readNetworkFile, + readOrInitializeNetworkFile, writeNetworkFile } from '$lib/server/networkPersistence'; import type { RequestHandler } from './$types'; +const staticDemoMode = import.meta.env.OND_STATIC_DEMO; +const staticDemoReason = 'Static demo deployment; changes are not persisted.'; + function serializeValidationErrors(errors: Array<{ path: string; message: string }>) { return errors.map((issue) => ({ path: issue.path, @@ -22,10 +25,35 @@ function resolveReadOnlyErrorMessage(reason: string | null): string { return `Write API unavailable: ${reason}`; } +async function resolveFailedReadPayload(error: unknown) { + const writableState = await getWritableState().catch(() => ({ + writable: false, + reason: null + })); + const message = + error instanceof Error && error.message ? error.message : 'Failed to read network data file'; + return { + error: message, + writable: false, + writableReason: writableState.reason + }; +} + export const GET: RequestHandler = async () => { + if (staticDemoMode) { + return json( + { + error: staticDemoReason, + writable: false, + writableReason: staticDemoReason + }, + { status: 503 } + ); + } + try { const [{ data, source, updatedAt }, writableState] = await Promise.all([ - readNetworkFile(), + readOrInitializeNetworkFile(), getWritableState() ]); const validation = validateNetworkData(data); @@ -33,7 +61,9 @@ export const GET: RequestHandler = async () => { return json( { error: 'Stored network data is invalid', - details: serializeValidationErrors(validation.errors) + details: serializeValidationErrors(validation.errors), + writable: false, + writableReason: writableState.reason }, { status: 500 } ); @@ -47,13 +77,22 @@ export const GET: RequestHandler = async () => { updatedAt }); } catch (error) { - const message = - error instanceof Error && error.message ? error.message : 'Failed to read network data file'; - return json({ error: message }, { status: 500 }); + return json(await resolveFailedReadPayload(error), { status: 500 }); } }; export const PUT: RequestHandler = async ({ request }) => { + if (staticDemoMode) { + return json( + { + error: resolveReadOnlyErrorMessage(staticDemoReason), + writable: false, + writableReason: staticDemoReason + }, + { status: 403 } + ); + } + const writableStateBeforeWrite = await getWritableState(); if (!writableStateBeforeWrite.writable) { return json( diff --git a/tests/networkPersistenceCore.test.mjs b/tests/networkPersistenceCore.test.mjs new file mode 100644 index 0000000..f500d0d --- /dev/null +++ b/tests/networkPersistenceCore.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +let moduleSequence = 0; + +function restoreEnvironment(name, value) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +async function withPersistenceEnvironment(run, options = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), 'ond-persistence-')); + const source = path.join(root, 'data', 'network.json'); + const backupDirectory = path.join(root, 'backups'); + const previousDataFile = process.env.NETWORK_DATA_FILE; + const previousBackupDirectory = process.env.NETWORK_BACKUP_DIR; + const previousReadOnly = process.env.NETWORK_READ_ONLY; + + process.env.NETWORK_DATA_FILE = source; + process.env.NETWORK_BACKUP_DIR = backupDirectory; + process.env.NETWORK_READ_ONLY = options.readOnly ? 'true' : 'false'; + moduleSequence += 1; + + try { + const persistence = await import( + `../src/lib/shared/networkPersistenceCore.mjs?test=${moduleSequence}` + ); + await run({ persistence, root, source, backupDirectory }); + } finally { + restoreEnvironment('NETWORK_DATA_FILE', previousDataFile); + restoreEnvironment('NETWORK_BACKUP_DIR', previousBackupDirectory); + restoreEnvironment('NETWORK_READ_ONLY', previousReadOnly); + await rm(root, { recursive: true, force: true }); + } +} + +test('initializes a missing writable data file with a blank network', async () => { + await withPersistenceEnvironment(async ({ persistence, source, backupDirectory }) => { + const result = await persistence.readOrInitializeNetworkFile(); + + assert.equal(result.initialized, true); + assert.deepEqual(result.data, { machines: [], devices: [] }); + assert.equal(result.source, source); + assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), { + machines: [], + devices: [] + }); + assert.deepEqual(await readdir(backupDirectory), []); + }); +}); + +async function assertInitializesBlankFile(contents) { + await withPersistenceEnvironment(async ({ persistence, source }) => { + await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false }); + await writeFile(source, contents, 'utf8'); + + const result = await persistence.readOrInitializeNetworkFile(); + + assert.equal(result.initialized, true); + assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), { + machines: [], + devices: [] + }); + }); +} + +test('initializes zero-byte and whitespace-only writable data files', async () => { + await assertInitializesBlankFile(''); + await assertInitializesBlankFile(' \n\t'); +}); + +test('does not rewrite an existing valid empty network', async () => { + await withPersistenceEnvironment(async ({ persistence, source }) => { + await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false }); + const before = await stat(source); + + const result = await persistence.readOrInitializeNetworkFile(); + const after = await stat(source); + + assert.equal(result.initialized, false); + assert.deepEqual(result.data, { machines: [], devices: [] }); + assert.equal(after.mtimeMs, before.mtimeMs); + }); +}); + +test('does not replace malformed or schema-invalid non-empty JSON', async () => { + await withPersistenceEnvironment(async ({ persistence, source }) => { + await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false }); + await writeFile(source, '{not json', 'utf8'); + + await assert.rejects(persistence.readOrInitializeNetworkFile(), SyntaxError); + assert.equal(await readFile(source, 'utf8'), '{not json'); + }); + + await withPersistenceEnvironment(async ({ persistence, source }) => { + await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false }); + await writeFile(source, '{}\n', 'utf8'); + + const result = await persistence.readOrInitializeNetworkFile(); + + assert.equal(result.initialized, false); + assert.deepEqual(result.data, {}); + assert.equal(await readFile(source, 'utf8'), '{}\n'); + }); +}); + +test('never initializes missing or blank files in explicit read-only mode', async () => { + await withPersistenceEnvironment( + async ({ persistence, source }) => { + await assert.rejects( + persistence.readOrInitializeNetworkFile(), + /Writes disabled by NETWORK_READ_ONLY=true/ + ); + await assert.rejects(stat(source), { code: 'ENOENT' }); + }, + { readOnly: true } + ); + + await withPersistenceEnvironment( + async ({ persistence, source }) => { + await mkdir(path.dirname(source), { recursive: true }); + await writeFile(source, ' \n', 'utf8'); + + await assert.rejects( + persistence.readOrInitializeNetworkFile(), + /Writes disabled by NETWORK_READ_ONLY=true/ + ); + assert.equal(await readFile(source, 'utf8'), ' \n'); + }, + { readOnly: true } + ); +}); + +test( + 'unwritable blank files remain untouched and report the permission failure', + { skip: process.platform === 'win32' }, + async () => { + await withPersistenceEnvironment(async ({ persistence, source }) => { + await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false }); + await writeFile(source, '', 'utf8'); + await chmod(source, 0o400); + + try { + const writableState = await persistence.getWritableState(); + assert.equal(writableState.writable, false); + assert.match(writableState.reason, /not readable and writable/); + await assert.rejects( + persistence.readOrInitializeNetworkFile(), + /not readable and writable/ + ); + assert.equal(await readFile(source, 'utf8'), ''); + } finally { + await chmod(source, 0o600); + } + }); + } +); diff --git a/vite.config.ts b/vite.config.ts index 2d35c4f..d967383 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,11 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +const staticDemoMode = process.env.OND_STATIC_DEMO === 'true'; + export default defineConfig({ + define: { + 'import.meta.env.OND_STATIC_DEMO': JSON.stringify(staticDemoMode) + }, plugins: [tailwindcss(), sveltekit()] });