diff --git a/.dockerignore b/.dockerignore index 49b5caab..00a856ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ desktop-app wiki node_modules *.md +!sample.md +!assets/linked-workspace-guide.md LICENSE docker-compose.yml Dockerfile diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ae6ec091..834c7343 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -42,6 +42,63 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} + - name: Install app and browser test dependencies + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Verify static build and unit tests + run: | + npm run build + npm run test:unit + + - name: Test Docker HTTP and local HTTPS end to end + run: bash tests/docker-smoke.sh + + - name: Run existing Chromium browser smoke + run: npm run test:e2e:smoke + + - name: Save HTTP and HTTPS screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-http-https-ui + path: docker-artifacts/docker-*.png + if-no-files-found: ignore + + - name: Build fresh Docker image for security scan + run: docker build --pull -t markdown-viewer:security-audit . + + - name: Scan image for all known OS and library vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: markdown-viewer:security-audit + format: json + output: docker-artifacts/trivy-vulnerabilities.json + scanners: vuln + vuln-type: os,library + severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL + exit-code: '0' + + - name: Block fixable critical and high vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: markdown-viewer:security-audit + format: table + scanners: vuln + vuln-type: os,library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + + - name: Upload full Docker vulnerability report + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-security-report + path: docker-artifacts/trivy-vulnerabilities.json + if-no-files-found: ignore + - name: Build and push Docker image uses: docker/build-push-action@v5 with: diff --git a/Dockerfile b/Dockerfile index bc7c50b4..5b6301d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,30 @@ -# Use nginx as the base image for serving static files -FROM nginx:alpine +# Serve the privacy-first Markdown Viewer web application with Nginx. +# Public HTTPS is terminated by an optional Caddy reverse proxy. +FROM nginx:1.31.8-alpine3.24-slim + +# Reduce attack surface by omitting optional Nginx modules, and apply +# all available security updates from the Alpine release repositories. +RUN apk upgrade --no-cache -# PERF-019: Only copy necessary web files (exclude .git, desktop-app, wiki, etc.) COPY index.html /usr/share/nginx/html/ 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 sw.js /usr/share/nginx/html/ COPY RELEASE_NOTES /usr/share/nginx/html/ COPY manifest.json /usr/share/nginx/html/ COPY robots.txt /usr/share/nginx/html/ COPY sitemap.xml /usr/share/nginx/html/ +COPY sample.md /usr/share/nginx/html/ COPY assets/ /usr/share/nginx/html/assets/ COPY seo/locales.mjs /usr/share/nginx/html/seo/ COPY seo/welcome-content.mjs /usr/share/nginx/html/seo/ +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf -# Create a custom nginx configuration with compression and security -# PERF-020: Added gzip compression for text-based assets -RUN echo 'server { \ - listen 80; \ - server_name localhost; \ - root /usr/share/nginx/html; \ - index index.html; \ - \ - # Enable gzip compression (PERF-020) \ - gzip on; \ - gzip_vary on; \ - gzip_proxied any; \ - gzip_comp_level 6; \ - gzip_min_length 256; \ - gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; \ - \ - # Handle client-side routing for SPA \ - location / { \ - 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"; \ - } \ - \ - # Security headers \ - add_header X-Frame-Options "SAMEORIGIN" always; \ - add_header X-Content-Type-Options "nosniff" always; \ - 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'"'"'" always; \ - }' > /etc/nginx/conf.d/default.conf - -# Expose port 80 EXPOSE 80 - -# Start nginx CMD ["nginx", "-g", "daemon off;"] - diff --git a/docker-compose.https-public.yml b/docker-compose.https-public.yml new file mode 100644 index 00000000..2ae69f60 --- /dev/null +++ b/docker-compose.https-public.yml @@ -0,0 +1,30 @@ +name: markdown-viewer-https-public +# Optional public HTTPS. Set MDV_DOMAIN to a DNS name you control. +# Only Caddy publishes ports; plain HTTP is confined to the Docker network. +services: + markdown-viewer: + build: + context: . + dockerfile: Dockerfile + expose: + - "80" + restart: unless-stopped + + caddy: + image: caddy:2-alpine + depends_on: + - markdown-viewer + environment: + MDV_DOMAIN: ${MDV_DOMAIN:?Set MDV_DOMAIN to your publicly resolving hostname} + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/Caddyfile.public:/etc/caddy/Caddyfile:ro + - mdv_caddy_data:/data + - mdv_caddy_config:/config + restart: unless-stopped + +volumes: + mdv_caddy_data: + mdv_caddy_config: diff --git a/docker-compose.https.yml b/docker-compose.https.yml new file mode 100644 index 00000000..5d52de16 --- /dev/null +++ b/docker-compose.https.yml @@ -0,0 +1,27 @@ +name: markdown-viewer-https-local +# Optional local HTTPS: https://localhost:8443 (self-signed local CA). +# The Nginx application has no published host HTTP port in this mode. +services: + markdown-viewer: + build: + context: . + dockerfile: Dockerfile + expose: + - "80" + restart: unless-stopped + + caddy: + image: caddy:2-alpine + depends_on: + - markdown-viewer + ports: + - "127.0.0.1:8443:443" + volumes: + - ./docker/Caddyfile.local:/etc/caddy/Caddyfile:ro + - mdv_caddy_data:/data + - mdv_caddy_config:/config + restart: unless-stopped + +volumes: + mdv_caddy_data: + mdv_caddy_config: diff --git a/docker/Caddyfile.local b/docker/Caddyfile.local new file mode 100644 index 00000000..17bc3c10 --- /dev/null +++ b/docker/Caddyfile.local @@ -0,0 +1,4 @@ +# Local HTTPS uses Caddy's private CA. Trust its root certificate on each client. +localhost { + reverse_proxy markdown-viewer:80 +} diff --git a/docker/Caddyfile.public b/docker/Caddyfile.public new file mode 100644 index 00000000..f7d38df0 --- /dev/null +++ b/docker/Caddyfile.public @@ -0,0 +1,5 @@ +# Public HTTPS requires MDV_DOMAIN DNS resolving to this host; Caddy handles ACME. +{$MDV_DOMAIN} { + header Strict-Transport-Security "max-age=31536000" + reverse_proxy markdown-viewer:80 +} diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..b82f7b6a --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,53 @@ +# Markdown Viewer is HTTP-only inside Docker. HTTPS is provided by Caddy. +server { + listen 80; + server_name _; + server_tokens off; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + + # Only SPA routes may fall back to index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # Unversioned scripts and styles should revalidate after an image upgrade. + # Do not define add_header in these locations: preserve inherited security headers. + location ~* \.(js|css)$ { + expires -1; + try_files $uri =404; + } + + # nginx:alpine does not consistently map .mjs to a JavaScript MIME type. + location ~* \.mjs$ { + types { application/javascript mjs; } + default_type application/javascript; + expires -1; + try_files $uri =404; + } + + # Short, bounded cache for unversioned image/font assets. + location ~* \.(png|jpe?g|gif|ico|svg|webp|woff2?)$ { + expires 7d; + try_files $uri =404; + } + + # Never expose accidentally copied dotfiles. + location ~ /\. { + return 404; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Do not use upgrade-insecure-requests on the plain HTTP listener (#290). + 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; +} diff --git a/sw.js b/sw.js index 574ea6aa..f5e90566 100644 --- a/sw.js +++ b/sw.js @@ -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', @@ -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', diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs new file mode 100644 index 00000000..e76cbed4 --- /dev/null +++ b/tests/docker-browser-smoke.cjs @@ -0,0 +1,89 @@ +'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; + if (!origin) throw new Error('Set DOCKER_SMOKE_URL to the HTTP or HTTPS test origin'); + const mode = origin.startsWith('https:') ? 'https' : 'http'; + // Chromium's network service validates Service Worker scripts independently of + // Playwright's page-level ignoreHTTPSErrors. This bypass applies only to + // the CI local-CA browser test; the shell smoke independently verifies the + // real localhost certificate against Caddy's own exported root CA. + const browser = await chromium.launch({ + headless: true, + args: mode === 'https' ? ['--ignore-certificate-errors'] : [] + }); + const failedLocalAssets = []; + + try { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + serviceWorkers: 'allow', + // Local HTTPS uses a private CA not yet installed on the CI runner. + ignoreHTTPSErrors: mode === 'https' + }); + const page = await context.newPage(); + + page.on('requestfailed', request => { + const type = request.resourceType(); + if (['script', 'stylesheet', 'document', 'font', 'image'].includes(type) && + new URL(request.url()).origin === origin) { + failedLocalAssets.push(`${request.url()}: ${request.failure()?.errorText}`); + } + }); + + const response = await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 45000 }); + assert.equal(response.status(), 200, `${mode} navigation must return 200`); + + await page.locator('#markdown-editor').waitFor({ state: 'visible', timeout: 45000 }); + await page.locator('#markdown-preview').waitFor({ state: 'visible', timeout: 45000 }); + await page.waitForFunction(() => + !!getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim(), + { timeout: 20000 } + ); + + await page.locator('#markdown-editor').fill('# Docker transport test\n\n**Live preview works**'); + await page.locator('#markdown-preview') + .getByRole('heading', { name: 'Docker transport test' }) + .waitFor({ timeout: 30000 }); + await page.locator('#markdown-preview') + .getByText('Live preview works') + .waitFor({ timeout: 30000 }); + + const swState = await page.evaluate(async () => { + if (!('serviceWorker' in navigator)) throw new Error('Service Worker API unavailable'); + await navigator.serviceWorker.register('/sw.js'); + const registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker install timed out')), 25000)) + ]); + const worker = registration.active; + if (!worker) throw new Error('Service Worker not active'); + 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(swState, 'activated', 'Offline precache must install successfully'); + assert.deepEqual(failedLocalAssets, [], 'No local CSS/JS/font/image resources may fail'); + + await fs.mkdir('docker-artifacts', { recursive: true }); + await page.screenshot({ path: `docker-artifacts/docker-${mode}.png`, timeout: 15000 }); + console.log(`PASS: ${origin} — styled editor, live preview, local assets, Service Worker`); + } finally { + await browser.close(); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh new file mode 100755 index 00000000..50cf5306 --- /dev/null +++ b/tests/docker-smoke.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +http=(docker compose -p mdv-ci-http -f docker-compose.yml) +https=(docker compose -p mdv-ci-https -f docker-compose.https.yml) +public=(docker compose -p mdv-ci-public -f docker-compose.https-public.yml) + +scratch="$(mktemp -d)" +cleanup() { + MDV_DOMAIN=example.com "${public[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + "${https[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + "${http[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + rm -rf "$scratch" +} +trap cleanup EXIT + +wait_for() { + local url="$1" + for _ in $(seq 1 80); do + if curl -kfsS --connect-timeout 2 --max-time 4 -o /dev/null "$url"; then + return 0 + fi + sleep 2 + done + echo "FAIL: Service did not become available: $url" >&2 + return 1 +} + +headers() { + curl -kfsSI --max-time 12 "$1" +} + +assert_headers() { + local url="$1" + local path="$2" + local expected="$3" + local response + response="$(headers "$url$path")" + echo "$response" | grep -qi '^HTTP/.* 200' || { echo "FAIL: $path is not HTTP 200" >&2; return 1; } + echo "$response" | grep -qi "^content-type: $expected" || { echo "FAIL: Incorrect MIME for $path: $response" >&2; return 1; } + echo "$response" | grep -qi '^x-content-type-options: nosniff' || { echo "FAIL: security header missing for $path" >&2; return 1; } + echo "$response" | grep -qi '^content-security-policy:' || { echo "FAIL: CSP missing for $path" >&2; return 1; } + if echo "$response" | grep -qi 'upgrade-insecure-requests'; then + echo "FAIL: HTTP-only Nginx must not send HTTPS-upgrade CSP" >&2 + return 1 + fi +} + +check_all_critical_assets() { + local url="$1" + while IFS= read -r path; do + if ! curl -kfsS --max-time 15 -o /dev/null "$url/${path#./}"; then + echo "FAIL: Service Worker critical asset missing: $path at $url" >&2 + return 1 + fi + done < <(node - <<'NODE' +const fs = require('node:fs'); +const sw = fs.readFileSync('sw.js', 'utf8'); +const critical = sw.match(/const CRITICAL_ASSETS = \[([\s\S]*?)\]/); +if (!critical) throw new Error('Missing CRITICAL_ASSETS'); +for (const match of critical[1].matchAll(/'([^']+)'/g)) { + console.log(match[1] === './' ? '' : match[1]); +} +NODE +) +} + +check_site() { + local url="$1" + assert_headers "$url" "/styles.css" 'text/css' + assert_headers "$url" "/script.js" 'application/javascript\|text/javascript' + assert_headers "$url" "/native-markdown-source.js" 'application/javascript\|text/javascript' + assert_headers "$url" "/assets/seo-metadata.mjs" 'application/javascript' + assert_headers "$url" "/assets/icon.jpg" 'image/jpeg' + for asset in /styles.css /script.js /assets/seo-metadata.mjs; do + if ! headers "$url$asset" | grep -qi '^cache-control: no-cache'; then + echo "FAIL: $asset must revalidate on upgrades" >&2 + return 1 + fi + done + for asset in /not-found.js /not-found.mjs /not-found.css; do + local status + status="$(curl -ksS -o /dev/null -w '%{http_code}' "$url$asset")" + [[ "$status" == 404 ]] || { echo "FAIL: $asset returned $status instead of 404" >&2; return 1; } + done + check_all_critical_assets "$url" + # A SPA index.html fallback is a false positive for a missing .md file. + curl -kfsS "$url/sample.md" | grep -Fq 'title: Welcome to Markdown Viewer' || { + echo "FAIL: packaged sample.md is missing or is HTML fallback" >&2 + return 1 + } + curl -kfsS "$url/assets/linked-workspace-guide.md" | grep -Fq '# Welcome to Linked Workspace' || { + echo "FAIL: packaged linked-workspace guide is missing or is HTML fallback" >&2 + return 1 + } +} + +echo "== Docker HTTP / default port 8080 ==" +"${http[@]}" up -d --build +wait_for "http://127.0.0.1:8080/" +check_site "http://127.0.0.1:8080" +DOCKER_SMOKE_URL=http://localhost:8080 node tests/docker-browser-smoke.cjs + +# Keep HTTP running while starting HTTPS to verify the two published ports coexist. +echo "== Docker local HTTPS / port 8443 ==" +"${https[@]}" up -d --build +wait_for "https://localhost:8443/" +check_site "https://localhost:8443" +# Verify HTTPS without -k by explicitly trusting the test Caddy CA. +"${https[@]}" cp caddy:/data/caddy/pki/authorities/local/root.crt "$scratch/local-root.crt" +curl --cacert "$scratch/local-root.crt" -fsS --max-time 12 -o /dev/null https://localhost:8443/ +DOCKER_SMOKE_URL=https://localhost:8443 node tests/docker-browser-smoke.cjs +check_site "http://127.0.0.1:8080" +echo "PASS: HTTP 8080 and HTTPS 8443 work concurrently" + +echo "== Validate optional public HTTPS config and Caddyfile without attempting certificate issuance ==" +MDV_DOMAIN=example.com "${public[@]}" config --quiet +MDV_DOMAIN=example.com "${public[@]}" run --rm --no-deps --entrypoint caddy caddy validate --config /etc/caddy/Caddyfile +echo "PASS: HTTP + HTTPS browser smoke, assets, CSP, MIME, caching, local TLS, public config." diff --git a/wiki/Docker-Deployment.md b/wiki/Docker-Deployment.md index 211fa2d6..aae95187 100644 --- a/wiki/Docker-Deployment.md +++ b/wiki/Docker-Deployment.md @@ -44,6 +44,54 @@ Stop: docker compose down ``` +## Optional HTTPS (no changes to the default port 8080) + +The default `docker-compose.yml` is still plain HTTP on **http://localhost:8080**. Its port mapping `8080:80` remains unchanged. Keep HTTP for local testing; do not expose this plain HTTP port to untrusted networks. To bind it only to localhost, customize the mapping to `127.0.0.1:8080:80`. + +### Local HTTPS on your own machine + +```bash +docker compose -f docker-compose.https.yml up -d --build +``` + +Open **https://localhost:8443**. Caddy supplies HTTPS using its **private local certificate authority** and proxies to Markdown Viewer's Nginx over the internal Docker network. The Nginx HTTP port is **not published** to the host in this mode. + +For browser trust without certificate warnings, export Caddy's local CA certificate: + +```bash +docker compose -f docker-compose.https.yml cp caddy:/data/caddy/pki/authorities/local/root.crt ./mdv-local-ca.crt +``` + +Trust this root CA **only on machines you control**, using the operating system's certificate-authority trust settings. Do not share its private key or install an untrusted third-party CA. For command-line *diagnostics only*, `curl -k https://localhost:8443` will connect without CA verification; do not disable TLS validation for real users. + +Stop without deleting the persisted CA: + +```bash +docker compose -f docker-compose.https.yml down +``` + +### Public HTTPS with your own domain + +Create a DNS record pointing a hostname you control (for example, `md.example.com`) to your Docker host, allow inbound TCP **80 and 443**, and set `MDV_DOMAIN` to the hostname **without** `https://`: + +```bash +MDV_DOMAIN=md.example.com docker compose -f docker-compose.https-public.yml up -d --build +``` + +Windows PowerShell: set `$env:MDV_DOMAIN = "md.example.com"` before running the `docker compose -f docker-compose.https-public.yml up -d --build` command. + +Open **https://md.example.com**. Caddy automatically requests and renews a publicly trusted certificate; its port 80 listener handles certificate validation and redirects to HTTPS. The reverse-proxied Nginx container is reachable **only inside the Docker network**. Caddy persists certificates and account state in Docker named volumes; preserve these volumes across upgrades. A public domain must resolve to your host and be reachable for certificate issuance. Existing `markdownviewer.pages.dev` points to Cloudflare Pages and is **not** automatically assigned to your Docker installation. + +Public HTTPS applies to the browser-to-Caddy connection. The private Docker-network hop from Caddy to Nginx remains HTTP; configure an additional TLS layer if your threat model requires encryption on that internal hop. + +To stop: + +```bash +MDV_DOMAIN=md.example.com docker compose -f docker-compose.https-public.yml down +``` + +The HTTPS configurations do not modify the ordinary `docker-compose.yml`, do not require a Markdown Viewer account, and do not change Cloudflare Pages hosting. If you operate an existing TLS reverse proxy, keep it and route it to the container's HTTP port instead of launching another Caddy on the same public ports. + ## Building Locally ```bash @@ -61,21 +109,13 @@ 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 - -The checked-in root `Dockerfile` does not copy: - -- `preview-worker.js`; or -- `sample.md`. - -Both files are listed in `sw.js` as critical precache assets. The consequences are: +## Docker Runtime and Offline Asset Integrity -- 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 image now includes the editor's root-level runtime dependencies (`browser-drop-source.js`, `native-markdown-source.js`, `preview-worker.js`, and `sample.md`) as well as the in-app linked-workspace guide. The Service Worker includes the native Markdown source in its critical asset list, so the expected local assets can precache successfully. JavaScript modules with the `.mjs` extension are served with `application/javascript`. -This is an implementation packaging limitation, not a documentation configuration step. The documentation audit records it without changing Docker behavior. +Mutable JS, CSS, and MJS assets use revalidation rather than an immutable one-year cache; images use a bounded seven-day cache. Security headers are inherited by static responses, and the HTTP-only Nginx listener does not send `upgrade-insecure-requests`. -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. +The Docker CI suite validates actual HTTP and local HTTPS containers, static asset status/MIME/headers, browser editor/Preview, and Service Worker activation. These tests don't guarantee correctness in every custom reverse proxy, domain, or browser setup. ## Nginx and Headers @@ -85,7 +125,7 @@ The Docker image uses `nginx:alpine`. Production deployments should keep these b - Serve `sample.md` because the Service Worker includes it in the critical precache. - Serve JavaScript files with correct MIME types. - Allow Service Worker registration from the app origin. -- Send security headers that match the app's CSP and deployment policy. The current Docker image includes: +- Send security headers that match the app's CSP and deployment policy. The Docker image includes: ```nginx add_header X-Frame-Options "SAMEORIGIN" always; @@ -98,7 +138,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 Docker image requires revalidation for JS/CSS/MJS rather than making unversioned application code immutable. Other local caches, including Service Worker caches, still require testing during upgrades. ## Reverse Proxy Examples