Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ desktop-app
wiki
node_modules
*.md
!sample.md
!assets/linked-workspace-guide.md
LICENSE
docker-compose.yml
Dockerfile
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 12 additions & 43 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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;"]

30 changes: 30 additions & 0 deletions docker-compose.https-public.yml
Original file line number Diff line number Diff line change
@@ -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:
27 changes: 27 additions & 0 deletions docker-compose.https.yml
Original file line number Diff line number Diff line change
@@ -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:
4 changes: 4 additions & 0 deletions docker/Caddyfile.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Local HTTPS uses Caddy's private CA. Trust its root certificate on each client.
localhost {
reverse_proxy markdown-viewer:80
}
5 changes: 5 additions & 0 deletions docker/Caddyfile.public
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
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
89 changes: 89 additions & 0 deletions tests/docker-browser-smoke.cjs
Original file line number Diff line number Diff line change
@@ -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;
});
Loading
Loading