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 .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ desktop-app
wiki
node_modules
*.md
!sample.md
LICENSE
docker-compose.yml
Dockerfile
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ jobs:
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}

- name: Install browser test dependencies
run: |
npm ci
npx playwright install --with-deps chromium

- name: Validate static build and unit tests
run: |
npm run build
npm run test:unit

- name: Build and smoke-test the actual Docker image
run: |
docker build -t markdown-viewer:smoke .
DOCKER_BROWSER_SMOKE=1 bash tests/docker-smoke.sh markdown-viewer:smoke

- name: Run existing browser smoke tests
run: npm run test:e2e:smoke

- name: Upload Docker browser screenshot
if: always()
uses: actions/upload-artifact@v4
with:
name: docker-ui-smoke
path: docker-artifacts/docker-ui.png
if-no-files-found: ignore

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
Expand Down
22 changes: 17 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ COPY workspace-storage.js /usr/share/nginx/html/
COPY visual-settings-adapters.js /usr/share/nginx/html/
COPY visual-settings-store.js /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY browser-drop-source.js /usr/share/nginx/html/
COPY native-markdown-source.js /usr/share/nginx/html/
COPY preview-worker.js /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
COPY sample.md /usr/share/nginx/html/
COPY sw.js /usr/share/nginx/html/
COPY RELEASE_NOTES /usr/share/nginx/html/
COPY manifest.json /usr/share/nginx/html/
Expand Down Expand Up @@ -38,10 +42,18 @@ RUN echo 'server { \
try_files $uri $uri/ /index.html; \
} \
\
# Cache static assets \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
# Mutable scripts/styles must revalidate after upgrades \
location ~* \.(js|css)$ { \
expires -1; \
} \
# Nginx Alpine does not recognize .mjs; modules need a JavaScript MIME type \
location ~* \.mjs$ { \
types { application/javascript mjs; } \
expires -1; \
} \
# Keep bounded caching for unversioned image filenames \
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ { \
expires 7d; \
} \
\
# Security headers \
Expand All @@ -50,7 +62,7 @@ RUN echo 'server { \
add_header X-XSS-Protection "1; mode=block" always; \
add_header Referrer-Policy "strict-origin-when-cross-origin" always; \
# PERF-029: Content Security Policy for defense-in-depth \
add_header Content-Security-Policy "default-src '"'"'self'"'"'; base-uri '"'"'self'"'"'; object-src '"'"'none'"'"'; script-src '"'"'self'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net '"'"'unsafe-inline'"'"'; worker-src '"'"'self'"'"'; connect-src '"'"'self'"'"' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src '"'"'self'"'"' data: blob: https:; style-src '"'"'self'"'"' '"'"'unsafe-inline'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src '"'"'self'"'"' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src '"'"'self'"'"' blob: data:; manifest-src '"'"'self'"'"'; upgrade-insecure-requests" always; \
add_header Content-Security-Policy "default-src '"'"'self'"'"'; base-uri '"'"'self'"'"'; object-src '"'"'none'"'"'; script-src '"'"'self'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net '"'"'unsafe-inline'"'"'; worker-src '"'"'self'"'"'; connect-src '"'"'self'"'"' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src '"'"'self'"'"' data: blob: https:; style-src '"'"'self'"'"' '"'"'unsafe-inline'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src '"'"'self'"'"' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src '"'"'self'"'"' blob: data:; manifest-src '"'"'self'"'"'" always; \
}' > /etc/nginx/conf.d/default.conf

# Expose port 80
Expand Down
2 changes: 2 additions & 0 deletions sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const CRITICAL_ASSETS = [
'./visual-settings-store.js',
'./script.js',
'./browser-drop-source.js',
'./native-markdown-source.js',
'./preview-worker.js',
'./styles.css',
'./assets/seo-metadata.mjs',
Expand Down Expand Up @@ -38,6 +39,7 @@ const NETWORK_FIRST_LOCAL_PATHS = new Set([
'/visual-settings-store.js',
'/script.js',
'/browser-drop-source.js',
'/native-markdown-source.js',
'/preview-worker.js',
'/styles.css',
'/assets/seo-metadata.mjs',
Expand Down
81 changes: 81 additions & 0 deletions tests/docker-browser-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';

const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { chromium } = require('@playwright/test');

async function main() {
const origin = process.env.DOCKER_SMOKE_URL || 'http://127.0.0.1:18719';
const browser = await chromium.launch({ headless: true });
let page;
const browserErrors = [];
try {
const context = await browser.newContext({ serviceWorkers: 'allow', viewport: { width: 1440, height: 900 } });
page = await context.newPage();
page.on('pageerror', error => browserErrors.push(error.message));
page.on('console', message => { if (message.type() === 'error') browserErrors.push(message.text()); });
const failedLocalRequests = [];
page.on('requestfailed', request => {
if (new URL(request.url()).origin === origin) {
failedLocalRequests.push(request.url() + ': ' + (request.failure()?.errorText || 'failed'));
}
});

const response = await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 30000 });
assert.equal(response.status(), 200, 'App navigation must succeed');
await page.locator('#markdown-editor').waitFor({ state: 'visible', timeout: 45000 });
await page.locator('#markdown-preview').waitFor({ state: 'visible', timeout: 30000 });

// Validate computed CSS rather than link.sheet timing (which can change
// during startup and stylesheet replacement). The HTTP smoke separately
// checks the stylesheets' MIME types, and the screenshot records the UI.
await page.waitForTimeout(500);
const styleInfo = await page.evaluate(() => ({
backgroundVariable: getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim()
}));
assert.ok(styleInfo.backgroundVariable, 'App CSS variables must be available');

await page.locator('#markdown-editor').fill('# Docker smoke\n\n**Container preview works**');
await page.locator('#markdown-preview').getByRole('heading', { name: 'Docker smoke' }).waitFor({ timeout: 30000 });
await page.locator('#markdown-preview').getByText('Container preview works').waitFor({ timeout: 30000 });

await fs.mkdir('docker-artifacts', { recursive: true });
await page.screenshot({ path: 'docker-artifacts/docker-ui.png', fullPage: false, timeout: 15000 });
console.log('PASS: Docker browser screenshot captured');

const activeWorker = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) throw new Error('Service Worker API unavailable');
// The web shell does not automatically register a worker in this flow.
// Explicitly install it here to verify the image's offline asset set.
await navigator.serviceWorker.register('/sw.js');
const ready = navigator.serviceWorker.ready;
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker not ready after 20s')), 20000));
const registration = await Promise.race([ready, timeout]);
const worker = registration.active;
if (!worker) throw new Error('Worker registration has no active worker');
if (worker.state !== 'activated') {
await Promise.race([
new Promise(resolve => {
worker.addEventListener('statechange', () => {
if (worker.state === 'activated') resolve();
});
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker activation timed out')), 20000))
]);
}
return worker.state;
});
assert.equal(activeWorker, 'activated', 'Service Worker must install and activate');

assert.deepEqual(failedLocalRequests, [], 'Local resources must not fail');
if (browserErrors.length) console.log('Non-fatal browser console diagnostics:', browserErrors.slice(-10));
console.log('PASS: Docker Chromium UI, styles, live Preview, and Service Worker');
} finally {
await browser.close();
}
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
84 changes: 84 additions & 0 deletions tests/docker-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail

image="${1:-markdown-viewer:smoke}"
port="${DOCKER_SMOKE_PORT:-18719}"
container="markdown-viewer-smoke-$$"
base="http://127.0.0.1:${port}"
scratch="$(mktemp -d)"

cleanup() {
docker rm -f "$container" >/dev/null 2>&1 || true
rm -rf "$scratch"
}
trap cleanup EXIT

docker run -d --name "$container" -p "127.0.0.1:${port}:80" "$image" >/dev/null

ready=0
for attempt in $(seq 1 45); do
if curl --silent --show-error --fail --max-time 3 "$base/" -o "$scratch/root" 2>/dev/null; then
ready=1
break
fi
sleep 1
done
if [[ "$ready" != 1 ]]; then
echo "Docker container did not become ready" >&2
docker logs "$container" >&2 || true
exit 1
fi

fetch_asset() {
local path="$1"
local expected_type="$2"
local key="${path//\//_}"
local headers="$scratch/${key}.headers"
local body="$scratch/${key}.body"
curl --silent --show-error --fail --max-time 15 -D "$headers" -o "$body" "$base/$path"
if ! tr -d '\r' < "$headers" | grep -Eiq "^content-type: (${expected_type})"; then
echo "Wrong content type for $path (possibly an HTML SPA fallback):" >&2
cat "$headers" >&2
exit 1
fi
if [[ ! -s "$body" ]]; then
echo "Empty resource: $path" >&2
exit 1
fi
echo "PASS $path"
}

fetch_asset "index.html" "text/html"
fetch_asset "styles.css" "text/css"
fetch_asset "assets/lucide-icons.css" "text/css"
fetch_asset "script.js" "(application|text)/javascript"
fetch_asset "workspace-storage.js" "(application|text)/javascript"
fetch_asset "browser-drop-source.js" "(application|text)/javascript"
fetch_asset "native-markdown-source.js" "(application|text)/javascript"
fetch_asset "preview-worker.js" "(application|text)/javascript"
fetch_asset "sw.js" "(application|text)/javascript"
fetch_asset "assets/seo-metadata.mjs" "(application|text)/javascript"
fetch_asset "manifest.json" "application/json"
fetch_asset "sample.md" "(text/markdown|text/plain|application/octet-stream)"

if ! grep -Fq 'Welcome to Markdown Viewer' "$scratch/sample.md.body"; then
echo "sample.md returned incorrect content" >&2
exit 1
fi
if ! grep -Fq '<!DOCTYPE html>' "$scratch/index.html.body"; then
echo "index.html did not return the app" >&2
exit 1
fi
if grep -Eiq '^content-security-policy:.*upgrade-insecure-requests' "$scratch/index.html.headers"; then
echo "Plain-HTTP Docker image must not upgrade requests to HTTPS" >&2
exit 1
fi
if ! tr -d '\r' < "$scratch/styles.css.headers" | grep -Eiq '^cache-control:.*no-cache'; then
echo "Mutable styles.css must revalidate" >&2
exit 1
fi

echo "PASS: Docker HTTP, CSP, MIME types, missing-asset prevention, and cache policy"
if [[ "${DOCKER_BROWSER_SMOKE:-0}" == "1" ]]; then
DOCKER_SMOKE_URL="$base" node tests/docker-browser-smoke.cjs
fi
18 changes: 8 additions & 10 deletions wiki/Docker-Deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,19 @@ On `localhost`, managed-media and stored Share Snapshot requests use the product

The container alone does not provide Cloudflare KV or Durable Objects. Self-hosted stored Share Snapshot and Live Share require separately deployed compatible endpoints.

## Known Stock Image Limitation
## Packaged Assets and Offline Support

The checked-in root `Dockerfile` does not copy:
The Docker image packages `browser-drop-source.js`, `native-markdown-source.js`, `preview-worker.js`, and `sample.md` along with the remaining web assets. `sw.js` precaches these files so first-party Preview Worker rendering and Service Worker installation have the required local resources.

- `preview-worker.js`; or
- `sample.md`.
Check the *content type*, not only HTTP status: an SPA fallback may return `200` and `text/html` for a missing local resource. Docker smoke tests verify both asset responses and the running editor.

Both files are listed in `sw.js` as critical precache assets. The consequences are:
## HTTP and HTTPS

- Documents eligible for Preview Worker rendering receive a Worker load failure and fall back to main-thread rendering; and
- Service Worker installation can fail because `cache.addAll()` rejects the complete precache when either asset returns `404`.
The container listens on **plain HTTP port 80**. Host mapping `8719:80` is valid; visit `http://localhost:8719` or `http://<LAN-host>:8719`. Docker does not expose TLS on that port.

This is an implementation packaging limitation, not a documentation configuration step. The documentation audit records it without changing Docker behavior.
The image's Content Security Policy intentionally **does not** set `upgrade-insecure-requests` on its HTTP endpoint. That directive would cause browsers on non-loopback hosts to try HTTPS for required same-origin files and leave the interface partially unstyled/unusable; Nginx would log TLS handshake bytes such as `\\x16\\x03` with HTTP 400.

If you maintain a custom image, copy both files to `/usr/share/nginx/html/`, rebuild, and verify that every `CRITICAL_ASSETS` URL in `sw.js` returns `200` with the correct MIME type.
For a public HTTPS deployment, terminate TLS at a trusted reverse proxy (e.g. Caddy, Traefik, or Nginx), proxy internally via HTTP, and apply your public HTTPS/HSTS policy at that TLS endpoint. Do not apply HSTS or HTTP-to-HTTPS upgrading to the bare HTTP container.

## Nginx and Headers

Expand All @@ -98,7 +96,7 @@ The Dockerfile also sets a Content-Security-Policy with the external origins req

Cloudflare Pages deployments use the stricter root `_headers` and `_redirects` files, including CSP, HSTS, clickjacking, permissions, cross-origin, and sensitive-path protections. When self-hosting behind Docker or another reverse proxy, keep the CSP compatible with `index.html`, lazy-loaded renderers, Share Snapshot, and Live Share instead of copying a wildcard policy.

Use long caching carefully. `sw.js`, `index.html`, `workspace-storage.js`, `script.js`, and `styles.css` are update-sensitive; stale versions can keep old app behavior alive.
The container's mutable JavaScript, CSS, and module files use revalidation rather than one-year immutable caching. Image assets use bounded caching, so replacing an unversioned image does not force clients to retain it for a year.

## Reverse Proxy Examples

Expand Down
Loading