From e6ae0dbb0958c3ff0b528aaee66e69f21b1cf5d9 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:02:52 +0530 Subject: [PATCH 1/9] feat(docker): repair runtime packaging and offer isolated HTTP and HTTPS modes --- .dockerignore | 2 ++ Dockerfile | 49 +++++------------------------- docker-compose.https-public.yml | 29 ++++++++++++++++++ docker-compose.https.yml | 26 ++++++++++++++++ docker/Caddyfile.local | 4 +++ docker/Caddyfile.public | 5 ++++ docker/nginx.conf | 53 +++++++++++++++++++++++++++++++++ sw.js | 2 ++ 8 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 docker-compose.https-public.yml create mode 100644 docker-compose.https.yml create mode 100644 docker/Caddyfile.local create mode 100644 docker/Caddyfile.public create mode 100644 docker/nginx.conf 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/Dockerfile b/Dockerfile index bc7c50b4..b8fee50a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,26 @@ -# Use nginx as the base image for serving static files +# Serve the privacy-first Markdown Viewer web application with Nginx. +# Public HTTPS is terminated by an optional Caddy reverse proxy. FROM nginx:alpine -# 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..c665c6db --- /dev/null +++ b/docker-compose.https-public.yml @@ -0,0 +1,29 @@ +# 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..28c23951 --- /dev/null +++ b/docker-compose.https.yml @@ -0,0 +1,26 @@ +# 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', From 7c8206078f2a162f88d4b74ff955c3ae3f694bc1 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:04:36 +0530 Subject: [PATCH 2/9] test(docker): exercise packaged HTTP and HTTPS images, assets, UI, and TLS configuration --- .github/workflows/docker-publish.yml | 24 +++++++ tests/docker-browser-smoke.cjs | 82 +++++++++++++++++++++ tests/docker-smoke.sh | 102 +++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 tests/docker-browser-smoke.cjs create mode 100755 tests/docker-smoke.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ae6ec091..65f33885 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -42,6 +42,30 @@ 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 and push Docker image uses: docker/build-push-action@v5 with: diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs new file mode 100644 index 00000000..9a4d4a8b --- /dev/null +++ b/tests/docker-browser-smoke.cjs @@ -0,0 +1,82 @@ +'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'; + const browser = await chromium.launch({ headless: true }); + 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..51acd4c0 --- /dev/null +++ b/tests/docker-smoke.sh @@ -0,0 +1,102 @@ +#!/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) + +cleanup() { + "${https[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + "${http[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true +} +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" +} + +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 +"${http[@]}" down --remove-orphans + +echo "== Docker local HTTPS / port 8443 ==" +"${https[@]}" up -d --build +wait_for "https://localhost:8443/" +check_site "https://localhost:8443" +DOCKER_SMOKE_URL=https://localhost:8443 node tests/docker-browser-smoke.cjs + +echo "== Validate optional public HTTPS config and Caddyfile without attempting certificate issuance ==" +MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml config --quiet +MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile +echo "PASS: HTTP + HTTPS browser smoke, assets, CSP, MIME, caching, local TLS, public config." From 30c998ff88ee1cd885a3c3db75f87a57001aac55 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:06:00 +0530 Subject: [PATCH 3/9] docs(docker): guide local and public HTTPS while preserving HTTP 8080 --- docker-compose.https-public.yml | 1 + docker-compose.https.yml | 1 + wiki/Docker-Deployment.md | 68 ++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/docker-compose.https-public.yml b/docker-compose.https-public.yml index c665c6db..2ae69f60 100644 --- a/docker-compose.https-public.yml +++ b/docker-compose.https-public.yml @@ -1,3 +1,4 @@ +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: diff --git a/docker-compose.https.yml b/docker-compose.https.yml index 28c23951..5d52de16 100644 --- a/docker-compose.https.yml +++ b/docker-compose.https.yml @@ -1,3 +1,4 @@ +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: 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 From 7e3fa6616364638480c1660be147a0bc1a6fbde5 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:07:35 +0530 Subject: [PATCH 4/9] test(docker): invoke Caddy validation through image entrypoint correctly --- tests/docker-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh index 51acd4c0..07e7cfc8 100755 --- a/tests/docker-smoke.sh +++ b/tests/docker-smoke.sh @@ -98,5 +98,5 @@ DOCKER_SMOKE_URL=https://localhost:8443 node tests/docker-browser-smoke.cjs echo "== Validate optional public HTTPS config and Caddyfile without attempting certificate issuance ==" MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml config --quiet -MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile +MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml run --rm --no-deps caddy validate --config /etc/caddy/Caddyfile echo "PASS: HTTP + HTTPS browser smoke, assets, CSP, MIME, caching, local TLS, public config." From e53dcea9600347fbf9537c5b412d08acb89bb04e Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:09:04 +0530 Subject: [PATCH 5/9] test(docker): verify Caddy local certificate chain and exercise HTTPS Service Worker --- tests/docker-browser-smoke.cjs | 9 ++++++++- tests/docker-smoke.sh | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index 9a4d4a8b..e76cbed4 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -8,7 +8,14 @@ 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'; - const browser = await chromium.launch({ headless: true }); + // 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 { diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh index 07e7cfc8..018fb569 100755 --- a/tests/docker-smoke.sh +++ b/tests/docker-smoke.sh @@ -5,9 +5,11 @@ 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) +scratch="$(mktemp -d)" cleanup() { "${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 @@ -94,6 +96,9 @@ 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 echo "== Validate optional public HTTPS config and Caddyfile without attempting certificate issuance ==" From e9d4660da88b208701b8658f95c23d0a8a49c080 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:11:14 +0530 Subject: [PATCH 6/9] test(docker): execute public Caddy config validation with explicit entrypoint --- tests/docker-smoke.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh index 018fb569..f648dfc3 100755 --- a/tests/docker-smoke.sh +++ b/tests/docker-smoke.sh @@ -4,9 +4,11 @@ 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" @@ -102,6 +104,6 @@ curl --cacert "$scratch/local-root.crt" -fsS --max-time 12 -o /dev/null https:// DOCKER_SMOKE_URL=https://localhost:8443 node tests/docker-browser-smoke.cjs echo "== Validate optional public HTTPS config and Caddyfile without attempting certificate issuance ==" -MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml config --quiet -MDV_DOMAIN=example.com docker compose -f docker-compose.https-public.yml run --rm --no-deps caddy validate --config /etc/caddy/Caddyfile +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." From 12322778852479ff353ab46ba1e1093e2ae4fe88 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:14:42 +0530 Subject: [PATCH 7/9] test(docker): verify HTTP/HTTPS coexistence and no Markdown asset fallback --- tests/docker-smoke.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh index f648dfc3..50cf5306 100755 --- a/tests/docker-smoke.sh +++ b/tests/docker-smoke.sh @@ -85,6 +85,15 @@ check_site() { [[ "$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 ==" @@ -92,8 +101,8 @@ echo "== Docker HTTP / default port 8080 ==" 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 -"${http[@]}" down --remove-orphans +# 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/" @@ -102,6 +111,8 @@ check_site "https://localhost:8443" "${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 From e37c79e505b3253d595afddcbcab17d68698327b Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:22:52 +0530 Subject: [PATCH 8/9] fix(docker): use patched slim Nginx and gate fixable high-severity CVEs --- .github/workflows/docker-publish.yml | 33 ++++++++++++++++++++++++++++ Dockerfile | 6 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 65f33885..ccd08439 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -66,6 +66,39 @@ jobs: 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@0.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@0.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 b8fee50a..5b6301d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ # Serve the privacy-first Markdown Viewer web application with Nginx. # Public HTTPS is terminated by an optional Caddy reverse proxy. -FROM nginx:alpine +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 COPY index.html /usr/share/nginx/html/ COPY workspace-storage.js /usr/share/nginx/html/ From a53f7428edc29a0e4d8237cf14af4f6b97b84dd6 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:23:37 +0530 Subject: [PATCH 9/9] ci(docker): reference published Trivy action release tag --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ccd08439..834c7343 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,7 +70,7 @@ jobs: run: docker build --pull -t markdown-viewer:security-audit . - name: Scan image for all known OS and library vulnerabilities - uses: aquasecurity/trivy-action@0.36.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: markdown-viewer:security-audit format: json @@ -81,7 +81,7 @@ jobs: exit-code: '0' - name: Block fixable critical and high vulnerabilities - uses: aquasecurity/trivy-action@0.36.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: markdown-viewer:security-audit format: table