Skip to content
5 changes: 5 additions & 0 deletions config/changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ const RELEASES = [
'New: Claude as OCR provider. It reads PDFs and images directly and uses the API key and model of the AI provider unless OCR has its own',
'Fix: Saving the settings no longer signs everybody out. The restart after a save created a new sign-in key, so every open session ended, and a page left open only showed vague errors such as "Model discovery failed". A session that ends now sends the page to the login screen',
'Fix: The manual tagging page uses the current design again. Its buttons, cards, document preview and tag chips were still built on styles that no longer exist',
'New: Single sign-on with any OpenID Connect provider (Authentik, Keycloak, Entra ID and others). Contributed by <a href="https://github.com/krauseerl">@krauseerl</a> in <a href="https://github.com/admonstrator/zettelrobbe/pull/372">#372</a>. E-mail addresses the provider marks as unverified are refused unless OIDC_ALLOW_UNVERIFIED_EMAIL=yes',
'New: Single sign-on can be set up on the settings page, including a "Test connection" button. Values set by the container stay locked there',
'Improvement: The settings page shows one topic at a time, grouped in a sidebar, with a search field and a save bar that counts unsaved changes across all topics',
'Improvement: The Claude model is picked from a dropdown filled by "Fetch Models", like every other model',
'Security: The login no longer reveals whether a username exists, a two-factor code is accepted only once, and the health endpoint keeps scanner and Paperless-ngx details behind authentication. Contributed by <a href="https://github.com/krauseerl">@krauseerl</a>',
],
},
];
Expand Down
47 changes: 37 additions & 10 deletions routes/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const {
ANTHROPIC_SUGGESTED_MODELS,
ANTHROPIC_EFFORT_LEVELS,
ANTHROPIC_DEFAULT_MAX_TOKENS,
isConfiguredAnthropicHost,
} = require('../services/anthropicSupport');
const config = require('../config/config.js');
require('dotenv').config({ path: '../data/.env' });
Expand Down Expand Up @@ -3917,9 +3918,17 @@ function resolveStoredAiToken(aiProvider) {
return '';
}

function resolveSettingsAiToken(aiProvider, token) {
function resolveSettingsAiToken(aiProvider, token, apiUrl) {
const normalizedToken = String(token || '').trim();
return normalizedToken || resolveStoredAiToken(aiProvider);
if (normalizedToken) return normalizedToken;
// The saved Claude key only goes to a Claude endpoint already configured.
const provider = String(aiProvider || '')
.trim()
.toLowerCase();
if (provider === 'anthropic' && !isConfiguredAnthropicHost(apiUrl)) {
return '';
}
return resolveStoredAiToken(aiProvider);
}

/* Accepts what the row menu sends (one id) and what the bulk menu sends (a
Expand Down Expand Up @@ -4149,8 +4158,10 @@ function resolveOcrApiKey(apiKey) {
/* resolveOcrApiKey() for a request that names its OCR provider. Claude OCR
must never receive a stored Mistral key: MISTRAL_API_KEY never belongs to
it, and OCR_API_KEY only when Claude is the OCR provider it was saved for.
An empty answer is fine — setupService falls back to the Anthropic AI key. */
function resolveOcrApiKeyForProvider(provider, apiKey) {
An empty answer is fine — setupService falls back to the Anthropic AI key.
`apiUrl` is where the request goes; the stored key is withheld from any
host that is not already configured for Claude. */
function resolveOcrApiKeyForProvider(provider, apiKey, apiUrl = '') {
const normalizedProvider = String(provider || '')
.trim()
.toLowerCase();
Expand All @@ -4166,7 +4177,9 @@ function resolveOcrApiKeyForProvider(provider, apiKey) {
const storedProvider = String(process.env.OCR_PROVIDER || '')
.trim()
.toLowerCase();
return storedProvider === 'anthropic' ? process.env.OCR_API_KEY || '' : '';
return storedProvider === 'anthropic' && isConfiguredAnthropicHost(apiUrl)
? process.env.OCR_API_KEY || ''
: '';
}

async function validatePaperlessConnectionForSetup(
Expand Down Expand Up @@ -5359,7 +5372,11 @@ router.post('/api/setup/ocr/models', express.json(), async (req, res) => {
apiUrl: req.body?.apiUrl,
// The wizard used to take the field at face value, so an operator whose
// key already sat in the environment was told to supply one.
apiKey: resolveOcrApiKeyForProvider(req.body?.provider, req.body?.apiKey),
apiKey: resolveOcrApiKeyForProvider(
req.body?.provider,
req.body?.apiKey,
req.body?.apiUrl || req.body?.anthropicApiUrl
),
anthropicApiKey: req.body?.anthropicApiKey,
anthropicApiUrl: req.body?.anthropicApiUrl,
anthropicModel: req.body?.anthropicModel,
Expand Down Expand Up @@ -5485,7 +5502,11 @@ router.post(
const validation = await validateAiConnectionForSetup({
aiProvider: req.body?.aiProvider,
apiUrl: req.body?.apiUrl,
token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token),
token: resolveSettingsAiToken(
req.body?.aiProvider,
req.body?.token,
req.body?.apiUrl
),
model: req.body?.model,
azureApiVersion: req.body?.azureApiVersion,
setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs,
Expand Down Expand Up @@ -5568,7 +5589,8 @@ router.post(
apiUrl: req.body?.apiUrl,
apiKey: resolveOcrApiKeyForProvider(
req.body?.provider,
req.body?.apiKey
req.body?.apiKey,
req.body?.apiUrl || req.body?.anthropicApiUrl
),
model: req.body?.model,
setupOcrValidationTimeoutMs:
Expand Down Expand Up @@ -5776,7 +5798,11 @@ router.post(
const result = await discoverAiModelsForSetup({
aiProvider: req.body?.aiProvider,
apiUrl: req.body?.apiUrl,
token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token),
token: resolveSettingsAiToken(
req.body?.aiProvider,
req.body?.token,
req.body?.apiUrl
),
setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs,
});

Expand Down Expand Up @@ -5897,7 +5923,8 @@ router.post(
apiUrl: req.body?.apiUrl,
apiKey: resolveOcrApiKeyForProvider(
req.body?.provider,
req.body?.apiKey
req.body?.apiKey,
req.body?.apiUrl || req.body?.anthropicApiUrl
),
anthropicApiKey: req.body?.anthropicApiKey,
anthropicApiUrl: req.body?.anthropicApiUrl,
Expand Down
53 changes: 49 additions & 4 deletions services/anthropicSupport.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
*/

const Anthropic = require('@anthropic-ai/sdk');
const { assertCompletionNotTruncated } = require('./serviceUtils');
const {
assertCompletionNotTruncated,
stripTrailingSlashes,
} = require('./serviceUtils');

/** The model a fresh installation starts with. */
const ANTHROPIC_DEFAULT_MODEL = 'claude-haiku-5-5';
Expand Down Expand Up @@ -323,16 +326,57 @@ function anthropicMaxTokens(requested, configured) {
*/
function createAnthropicClient({ apiKey, apiUrl = '', timeoutMs } = {}) {
const clientOptions = { apiKey: String(apiKey || '') };
const baseURL = String(apiUrl || '')
.trim()
.replace(/\/+$/, '');
const baseURL = stripTrailingSlashes(String(apiUrl || '').trim());
if (baseURL) clientOptions.baseURL = baseURL;
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
clientOptions.timeout = timeoutMs;
}
return new Anthropic(clientOptions);
}

/** Where a client goes when no proxy URL is set. */
const ANTHROPIC_DEFAULT_ORIGIN = 'https://api.anthropic.com';

/**
* Whether a request targets a Claude endpoint this installation already uses:
* ANTHROPIC_API_URL (empty means api.anthropic.com), or OCR_API_URL while
* Claude is the OCR provider. A stored key is only substituted for an empty
* key field when this holds; otherwise any signed-in caller could name their
* own host and receive the saved key in the x-api-key header. Typed keys are
* not affected. An empty URL passes: the request then goes to the configured
* proxy or to api.anthropic.com, both of which already hold the key.
*
* @param {string} requestedUrl - the URL the request names, empty for none
* @param {NodeJS.ProcessEnv} [env]
* @returns {boolean}
*/
function isConfiguredAnthropicHost(requestedUrl, env = process.env) {
const origin = (value) => {
const trimmed = String(value || '').trim();
if (!trimmed) return ANTHROPIC_DEFAULT_ORIGIN;
try {
const parsed = new URL(trimmed);
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
} catch {
return null;
}
};

if (!String(requestedUrl || '').trim()) return true;
const requested = origin(requestedUrl);
if (!requested) return false;

const configured = [origin(env.ANTHROPIC_API_URL)];
const ocrIsClaude =
String(env.OCR_PROVIDER || '')
.trim()
.toLowerCase() === 'anthropic';
if (ocrIsClaude && String(env.OCR_API_URL || '').trim()) {
configured.push(origin(env.OCR_API_URL));
}
return configured.includes(requested);
}

/**
* The visible answer of a Messages response: every text block, in order.
* Thinking blocks are no part of it, and a response can begin with them, so
Expand Down Expand Up @@ -435,6 +479,7 @@ module.exports = {
anthropicThinkingParams,
anthropicMaxTokens,
createAnthropicClient,
isConfiguredAnthropicHost,
anthropicMessageText,
mapAnthropicUsage,
assertAnthropicCompleted,
Expand Down
14 changes: 10 additions & 4 deletions services/mistralOcrService.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ const popplerService = require('./popplerService');
const documentModel = require('../models/document');
const AIServiceFactory = require('./aiServiceFactory');
const documentProcessingService = require('./documentProcessingService');
const { isTimeoutError, buildTimeoutErrorMessage } = require('./serviceUtils');
const {
isTimeoutError,
buildTimeoutErrorMessage,
stripTrailingSlashes,
} = require('./serviceUtils');
const {
ANTHROPIC_DEFAULT_MODEL,
anthropicThinkingParams,
Expand Down Expand Up @@ -195,9 +199,11 @@ class MistralOcrService {
// OCR_API_URL only when the operator set one (a proxy used for OCR
// alone); otherwise the AI provider's ANTHROPIC_API_URL. Empty means
// api.anthropic.com.
return String(config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || '')
.trim()
.replace(/\/+$/, '');
return stripTrailingSlashes(
String(
config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || ''
).trim()
);
}

if (this.provider === 'ollama') {
Expand Down
3 changes: 2 additions & 1 deletion services/oidcService.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

const config = require('../config/config');
const { stripTrailingSlashes } = require('./serviceUtils');

// Loaded on first use, not at startup: an install that never turns SSO on
// keeps running even on an image whose dependencies predate openid-client.
Expand All @@ -43,7 +44,7 @@ const TOKEN_AUTH_METHODS = [
];

function stripTrailingSlash(value) {
return String(value).replace(/\/+$/, '');
return stripTrailingSlashes(String(value));
}

/**
Expand Down
25 changes: 14 additions & 11 deletions services/setupService.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
ANTHROPIC_DEFAULT_MODEL,
ANTHROPIC_SUGGESTED_MODELS,
createAnthropicClient,
isConfiguredAnthropicHost,
} = require('./anthropicSupport');

const CUSTOM_PROVIDER_FALLBACK_API_KEY = 'no-auth-required';
Expand Down Expand Up @@ -659,25 +660,27 @@ class SetupService {
*
* `anthropicApiKey` / `anthropicApiUrl` carry AI provider values that are
* not saved yet (the setup wizard collects both before anything is
* written); the environment covers the ones that are.
* written); the environment covers the ones that are. The stored
* ANTHROPIC_API_KEY is only used when the URL is one already configured
* (see isConfiguredAnthropicHost).
*
* @param {object} options
* @returns {{apiKey: string, apiUrl: string}}
*/
resolveAnthropicOcrConnection(options = {}) {
const firstSet = (...values) =>
values.map((value) => String(value || '').trim()).find(Boolean) || '';
const apiUrl = firstSet(
options.apiUrl,
options.anthropicApiUrl,
process.env.ANTHROPIC_API_URL
);
const storedKey = isConfiguredAnthropicHost(apiUrl)
? process.env.ANTHROPIC_API_KEY
: '';
return {
apiKey: firstSet(
options.apiKey,
options.anthropicApiKey,
process.env.ANTHROPIC_API_KEY
),
apiUrl: firstSet(
options.apiUrl,
options.anthropicApiUrl,
process.env.ANTHROPIC_API_URL
),
apiKey: firstSet(options.apiKey, options.anthropicApiKey, storedKey),
apiUrl,
};
}

Expand Down
44 changes: 44 additions & 0 deletions tests/test-anthropic-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ function startFakeAnthropic() {
setScenario({
PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes',
ANTHROPIC_API_KEY: GOOD_KEY,
ANTHROPIC_API_URL: fake.url,
});
let response = await request('POST', '/api/settings/ai/models', {
aiProvider: 'anthropic',
Expand All @@ -461,6 +462,49 @@ function startFakeAnthropic() {
assert.match(body.message, /suggested Claude models/);
});

await test('the stored Claude key never goes to a host that is not configured', async () => {
// The fake answers on 127.0.0.1 and localhost alike; only the stored
// URL names one of them, so the other counts as somebody else's host.
const otherHost = fake.url.includes('127.0.0.1')
? fake.url.replace('127.0.0.1', 'localhost')
: fake.url.replace('localhost', '127.0.0.1');
setScenario({
PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes',
ANTHROPIC_API_KEY: GOOD_KEY,
ANTHROPIC_API_URL: fake.url,
OCR_PROVIDER: 'anthropic',
OCR_API_KEY: GOOD_KEY,
});

let response = await request('POST', '/api/settings/ai/models', {
aiProvider: 'anthropic',
apiUrl: otherHost,
token: '',
});
let body = await response.json();
assert.strictEqual(response.status, 200);
assert.strictEqual(body.live, false, 'AI: no stored key for a new host');

response = await request('POST', '/api/settings/ocr/models', {
provider: 'anthropic',
apiUrl: otherHost,
apiKey: '',
});
body = await response.json();
assert.strictEqual(response.status, 200);
assert.strictEqual(body.live, false, 'OCR: no stored key for a new host');

const connection = setupService.resolveAnthropicOcrConnection({
anthropicApiUrl: otherHost,
});
assert.strictEqual(connection.apiKey, '');
assert.strictEqual(
setupService.resolveAnthropicOcrConnection({}).apiKey,
GOOD_KEY,
'the configured URL still gets the stored key'
);
});

// ── validateOcrConfig for Claude OCR ────────────────────────────────────
await test('validateOcrConfig(anthropic) runs one OCR round trip with the typed key', async () => {
setScenario({ ANTHROPIC_API_KEY: AI_KEY });
Expand Down
2 changes: 1 addition & 1 deletion tests/test-ocr-model-fetch-key.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ check('every OCR route resolves the key before using it', () => {
// which hands every other provider to resolveOcrApiKey() unchanged.
const calls = (
source.match(
/resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey\s*\)/g
/resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey,\s*req\.body\?\.apiUrl \|\| req\.body\?\.anthropicApiUrl\s*\)/g
) || []
).length;
assert.strictEqual(
Expand Down
Loading