diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl index bea8cdddb..5e7b70777 100644 --- a/.claude/metrics/review-metrics.jsonl +++ b/.claude/metrics/review-metrics.jsonl @@ -67,3 +67,4 @@ {"pr":404,"issues":[359],"epic":4,"type":"feat","mergedAt":"2026-03-03T11:15:00Z","filesChanged":2,"linesChanged":94,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":405,"issues":[394],"epic":4,"type":"feat","mergedAt":"2026-03-03T12:05:00Z","filesChanged":12,"linesChanged":974,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":1,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":2}],"totalFindings":{"critical":0,"high":1,"medium":0,"low":1,"informational":1}} {"pr":410,"issues":[407,408,409],"epic":null,"type":"fix","mergedAt":"2026-03-03T00:00:00Z","filesChanged":20,"linesChanged":566,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0}} +{"pr":412,"issues":[411],"epic":null,"type":"feat","mergedAt":"2026-03-03T00:00:00Z","filesChanged":9,"linesChanged":255,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1}} diff --git a/CLAUDE.md b/CLAUDE.md index 03e3a805f..6d29b2bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,16 +389,17 @@ Hand-written SQL files in `server/src/db/migrations/` with a numeric prefix (e.g ### Environment Variables -| Variable | Default | Description | -| --------------------- | -------------------------- | --------------------------------------------- | -| `PORT` | `3000` | Server port | -| `HOST` | `0.0.0.0` | Server bind address | -| `DATABASE_URL` | `/app/data/cornerstone.db` | SQLite database path | -| `LOG_LEVEL` | `info` | Log level (trace/debug/info/warn/error/fatal) | -| `NODE_ENV` | `production` | Environment | -| `CLIENT_DEV_PORT` | `5173` | Webpack dev server port (development only) | -| `PAPERLESS_URL` | (none) | Paperless-ngx instance base URL | -| `PAPERLESS_API_TOKEN` | (none) | Paperless-ngx API authentication token | +| Variable | Default | Description | +| ------------------------ | -------------------------- | ----------------------------------------------------------------------------------- | +| `PORT` | `3000` | Server port | +| `HOST` | `0.0.0.0` | Server bind address | +| `DATABASE_URL` | `/app/data/cornerstone.db` | SQLite database path | +| `LOG_LEVEL` | `info` | Log level (trace/debug/info/warn/error/fatal) | +| `NODE_ENV` | `production` | Environment | +| `CLIENT_DEV_PORT` | `5173` | Webpack dev server port (development only) | +| `PAPERLESS_URL` | (none) | Paperless-ngx instance base URL | +| `PAPERLESS_API_TOKEN` | (none) | Paperless-ngx API authentication token | +| `PAPERLESS_EXTERNAL_URL` | (none) | Browser-facing URL for Paperless-ngx links (falls back to `PAPERLESS_URL` if unset) | Production images use Docker Hardened Images (DHI). See `Dockerfile` and `docker-compose.yml` for build/deploy details. diff --git a/docker-compose.yml b/docker-compose.yml index 9b5de073d..59cc56755 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,9 @@ services: - .env # Paperless-ngx integration (optional — add to your .env file): # environment: - # - PAPERLESS_URL=http://paperless:8000 + # - PAPERLESS_URL=http://paperless:8000 # Internal URL used by the server for API calls # - PAPERLESS_API_TOKEN=your-paperless-api-token + # - PAPERLESS_EXTERNAL_URL=https://paperless.example.com # Optional: browser-accessible URL for "View in Paperless-ngx" links restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"] diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index aea212a41..d20041da7 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -55,7 +55,8 @@ The document integration is automatically enabled when both `PAPERLESS_URL` and | Variable | Default | Description | |----------|---------|-------------| -| `PAPERLESS_URL` | -- | Base URL of your Paperless-ngx instance (e.g., `https://paperless.example.com`) | +| `PAPERLESS_URL` | -- | Base URL of your Paperless-ngx instance used by the server for API calls (e.g., `http://paperless:8000` in Docker) | | `PAPERLESS_API_TOKEN` | -- | API authentication token from Paperless-ngx | +| `PAPERLESS_EXTERNAL_URL` | -- | Browser-facing URL for Paperless-ngx links (e.g., `https://paperless.example.com`). If unset, falls back to `PAPERLESS_URL`. | For detailed setup instructions, see [Documents Setup](/guides/documents/setup). diff --git a/docs/src/guides/documents/setup.md b/docs/src/guides/documents/setup.md index d1034c2c3..70d74b9d2 100644 --- a/docs/src/guides/documents/setup.md +++ b/docs/src/guides/documents/setup.md @@ -11,10 +11,11 @@ To enable the document integration, configure two environment variables and rest | Variable | Required | Description | |----------|----------|-------------| -| `PAPERLESS_URL` | Yes | Base URL of your Paperless-ngx instance (e.g., `https://paperless.example.com`) | +| `PAPERLESS_URL` | Yes | Base URL of your Paperless-ngx instance used by the server for API calls (e.g., `http://paperless:8000` in Docker) | | `PAPERLESS_API_TOKEN` | Yes | API authentication token from Paperless-ngx | +| `PAPERLESS_EXTERNAL_URL` | No | Browser-facing URL for Cornerstone browser links to Paperless-ngx (e.g., `https://paperless.example.com`). If unset, falls back to `PAPERLESS_URL`. | -Both variables must be set for the integration to activate. If either is missing, Cornerstone will show a "Not Configured" message on the Documents page and in the document linking sections. +`PAPERLESS_URL` and `PAPERLESS_API_TOKEN` must be set for the integration to activate. If either is missing, Cornerstone will show a "Not Configured" message on the Documents page and in the document linking sections. ### Getting Your API Token @@ -32,6 +33,21 @@ docker run -d \ -v cornerstone-data:/app/data \ -e PAPERLESS_URL=https://paperless.example.com \ -e PAPERLESS_API_TOKEN=your-api-token-here \ + -e PAPERLESS_EXTERNAL_URL=https://paperless.example.com \ + steilerdev/cornerstone:latest +``` + +If Cornerstone and Paperless-ngx are on the same Docker network, you can use separate URLs: + +```bash +docker run -d \ + --name cornerstone \ + -p 3000:3000 \ + -v cornerstone-data:/app/data \ + --network my-network \ + -e PAPERLESS_URL=http://paperless:8000 \ + -e PAPERLESS_API_TOKEN=your-api-token-here \ + -e PAPERLESS_EXTERNAL_URL=https://paperless.example.com \ steilerdev/cornerstone:latest ``` @@ -50,6 +66,7 @@ services: environment: PAPERLESS_URL: https://paperless.example.com PAPERLESS_API_TOKEN: your-api-token-here + PAPERLESS_EXTERNAL_URL: https://paperless.example.com ``` Or set them in your `.env` file: @@ -57,6 +74,33 @@ Or set them in your `.env` file: ```env PAPERLESS_URL=https://paperless.example.com PAPERLESS_API_TOKEN=your-api-token-here +PAPERLESS_EXTERNAL_URL=https://paperless.example.com +``` + +For a Docker network setup where Cornerstone and Paperless-ngx are connected internally: + +```yaml +services: + cornerstone: + image: steilerdev/cornerstone:latest + ports: + - '3000:3000' + volumes: + - cornerstone-data:/app/data + networks: + - internal + environment: + PAPERLESS_URL: http://paperless:8000 + PAPERLESS_API_TOKEN: your-api-token-here + PAPERLESS_EXTERNAL_URL: https://paperless.example.com + + paperless: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + networks: + - internal + +networks: + internal: ``` ## Network Requirements diff --git a/server/src/plugins/config.test.ts b/server/src/plugins/config.test.ts index 52e5c2001..07fceae44 100644 --- a/server/src/plugins/config.test.ts +++ b/server/src/plugins/config.test.ts @@ -26,6 +26,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { oidcRedirectUri: undefined, oidcEnabled: false, paperlessUrl: undefined, + paperlessExternalUrl: undefined, paperlessApiToken: undefined, paperlessEnabled: false, }); @@ -55,6 +56,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { oidcRedirectUri: undefined, oidcEnabled: false, paperlessUrl: undefined, + paperlessExternalUrl: undefined, paperlessApiToken: undefined, paperlessEnabled: false, }); @@ -86,6 +88,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { oidcRedirectUri: undefined, oidcEnabled: false, paperlessUrl: undefined, + paperlessExternalUrl: undefined, paperlessApiToken: undefined, paperlessEnabled: false, }); @@ -112,6 +115,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { oidcRedirectUri: undefined, oidcEnabled: false, paperlessUrl: undefined, + paperlessExternalUrl: undefined, paperlessApiToken: undefined, paperlessEnabled: false, }); @@ -365,6 +369,83 @@ describe('Configuration Module - loadConfig() Pure Function', () => { }); }); + describe('PAPERLESS_EXTERNAL_URL Configuration', () => { + it('PAPERLESS_EXTERNAL_URL not set → paperlessExternalUrl is undefined', () => { + const config = loadConfig({}); + expect(config.paperlessExternalUrl).toBeUndefined(); + }); + + it('valid https:// URL → paperlessExternalUrl equals that URL', () => { + const config = loadConfig({ + PAPERLESS_EXTERNAL_URL: 'https://paperless.example.com', + }); + + expect(config.paperlessExternalUrl).toBe('https://paperless.example.com'); + }); + + it('valid http:// URL → accepted', () => { + const config = loadConfig({ + PAPERLESS_EXTERNAL_URL: 'http://paperless.internal:8000', + }); + + expect(config.paperlessExternalUrl).toBe('http://paperless.internal:8000'); + }); + + it('file:// scheme → throws error containing PAPERLESS_EXTERNAL_URL must use http or https scheme, got: file', () => { + expect(() => + loadConfig({ + PAPERLESS_EXTERNAL_URL: 'file:///etc/passwd', + }), + ).toThrow('PAPERLESS_EXTERNAL_URL must use http or https scheme, got: file'); + }); + + it('ftp:// scheme → throws error containing PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ftp', () => { + expect(() => + loadConfig({ + PAPERLESS_EXTERNAL_URL: 'ftp://host/resource', + }), + ).toThrow('PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ftp'); + }); + + it('invalid URL string → throws error containing PAPERLESS_EXTERNAL_URL must be a valid URL', () => { + expect(() => + loadConfig({ + PAPERLESS_EXTERNAL_URL: 'not-a-url', + }), + ).toThrow('PAPERLESS_EXTERNAL_URL must be a valid URL, got: not-a-url'); + }); + + it('empty string → treated as undefined, paperlessExternalUrl is undefined', () => { + const config = loadConfig({ + PAPERLESS_EXTERNAL_URL: '', + }); + + expect(config.paperlessExternalUrl).toBeUndefined(); + }); + + it('external URL set without PAPERLESS_URL/PAPERLESS_API_TOKEN → paperlessEnabled is false, but paperlessExternalUrl is set', () => { + const config = loadConfig({ + PAPERLESS_EXTERNAL_URL: 'https://external.example.com', + }); + + expect(config.paperlessEnabled).toBe(false); + expect(config.paperlessExternalUrl).toBe('https://external.example.com'); + }); + + it('all three paperless vars set → paperlessEnabled is true, both URLs set', () => { + const config = loadConfig({ + PAPERLESS_URL: 'http://paperless:8000', + PAPERLESS_EXTERNAL_URL: 'https://external.example.com', + PAPERLESS_API_TOKEN: 'test-token', + }); + + expect(config.paperlessEnabled).toBe(true); + expect(config.paperlessUrl).toBe('http://paperless:8000'); + expect(config.paperlessExternalUrl).toBe('https://external.example.com'); + expect(config.paperlessApiToken).toBe('test-token'); + }); + }); + describe('Scenario 6: Collect All Validation Errors', () => { it('reports multiple bad values in a single error', () => { expect(() => diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index 4f2814646..a090520d4 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -16,6 +16,7 @@ export interface AppConfig { oidcRedirectUri?: string; oidcEnabled: boolean; paperlessUrl?: string; + paperlessExternalUrl?: string; paperlessApiToken?: string; paperlessEnabled: boolean; } @@ -126,6 +127,25 @@ export function loadConfig(env: Record): AppConfig { } } + // Paperless-ngx external URL (optional, used for browser-facing links) + const paperlessExternalUrlRaw = getValue('PAPERLESS_EXTERNAL_URL'); + let paperlessExternalUrl: string | undefined = undefined; + if (paperlessExternalUrlRaw) { + try { + const parsed = new URL(paperlessExternalUrlRaw); + const allowedSchemes = ['http:', 'https:']; + if (!allowedSchemes.includes(parsed.protocol)) { + errors.push( + `PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ${parsed.protocol.replace(':', '')}`, + ); + } else { + paperlessExternalUrl = paperlessExternalUrlRaw; + } + } catch { + errors.push(`PAPERLESS_EXTERNAL_URL must be a valid URL, got: ${paperlessExternalUrlRaw}`); + } + } + // Paperless-ngx is enabled when both URL and API token are set const paperlessEnabled = !!(paperlessUrl && paperlessApiToken); @@ -149,6 +169,7 @@ export function loadConfig(env: Record): AppConfig { oidcRedirectUri, oidcEnabled, paperlessUrl, + paperlessExternalUrl, paperlessApiToken, paperlessEnabled, }; diff --git a/server/src/routes/paperless.test.ts b/server/src/routes/paperless.test.ts index 704b5f02c..4b0559a20 100644 --- a/server/src/routes/paperless.test.ts +++ b/server/src/routes/paperless.test.ts @@ -115,6 +115,7 @@ describe('Paperless Routes', () => { await app.close(); } + delete process.env.PAPERLESS_EXTERNAL_URL; process.env = originalEnv; try { @@ -146,6 +147,17 @@ describe('Paperless Routes', () => { app = await buildApp(); } + /** + * Helper: Re-build the app with Paperless and External URL configured + */ + async function rebuildAppWithPaperlessAndExternalUrl(): Promise { + await app.close(); + process.env.PAPERLESS_URL = 'http://paperless:8000'; + process.env.PAPERLESS_API_TOKEN = 'test-token'; + process.env.PAPERLESS_EXTERNAL_URL = 'https://external.example.com'; + app = await buildApp(); + } + // ─── GET /api/paperless/status ───────────────────────────────────────────── describe('GET /api/paperless/status', () => { @@ -213,6 +225,61 @@ describe('Paperless Routes', () => { expect(body.error).toContain('ECONNREFUSED'); expect(body.paperlessUrl).toBe('http://paperless:8000'); }); + + it('when PAPERLESS_EXTERNAL_URL is set and Paperless is reachable → paperlessUrl equals external URL', async () => { + await rebuildAppWithPaperlessAndExternalUrl(); + const { cookie } = await createUserWithSession(); + + mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 10 })); + + const response = await app.inject({ + method: 'GET', + url: '/api/paperless/status', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.configured).toBe(true); + expect(body.reachable).toBe(true); + expect(body.paperlessUrl).toBe('https://external.example.com'); + }); + + it('when PAPERLESS_EXTERNAL_URL is set and Paperless is unreachable → paperlessUrl still equals external URL', async () => { + await rebuildAppWithPaperlessAndExternalUrl(); + const { cookie } = await createUserWithSession(); + + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const response = await app.inject({ + method: 'GET', + url: '/api/paperless/status', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.configured).toBe(true); + expect(body.reachable).toBe(false); + expect(body.paperlessUrl).toBe('https://external.example.com'); + }); + + it('backward compatibility: no external URL → paperlessUrl shows internal URL', async () => { + await rebuildAppWithPaperless(); + const { cookie } = await createUserWithSession(); + + mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 10 })); + + const response = await app.inject({ + method: 'GET', + url: '/api/paperless/status', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.paperlessUrl).toBe('http://paperless:8000'); + }); }); // ─── GET /api/paperless/documents ───────────────────────────────────────── diff --git a/server/src/routes/paperless.ts b/server/src/routes/paperless.ts index 7a32bd0be..1dea96170 100644 --- a/server/src/routes/paperless.ts +++ b/server/src/routes/paperless.ts @@ -121,7 +121,10 @@ export default async function paperlessRoutes(fastify: FastifyInstance) { fastify.config.paperlessUrl!, fastify.config.paperlessApiToken!, ); - return reply.status(200).send({ ...status, paperlessUrl: fastify.config.paperlessUrl ?? null }); + return reply.status(200).send({ + ...status, + paperlessUrl: fastify.config.paperlessExternalUrl ?? fastify.config.paperlessUrl ?? null, + }); }); /** diff --git a/shared/src/types/document.ts b/shared/src/types/document.ts index edb26c4c5..f913018bd 100644 --- a/shared/src/types/document.ts +++ b/shared/src/types/document.ts @@ -134,7 +134,11 @@ export interface PaperlessStatusResponse { reachable: boolean; /** Human-readable error message if not reachable. */ error: string | null; - /** The Paperless-ngx base URL, present only when configured. */ + /** + * The browser-facing Paperless-ngx base URL, present only when configured. + * Returns PAPERLESS_EXTERNAL_URL when set, otherwise falls back to PAPERLESS_URL. + * Used by the frontend to construct "View in Paperless-ngx" links. + */ paperlessUrl: string | null; }