Allow internal Paperless webhook target

This commit is contained in:
2026-05-08 00:09:13 +02:00
parent 58e737d5cd
commit bdd8501467
10 changed files with 40 additions and 4 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/ node_modules/
frontend/node_modules/ frontend/node_modules/
frontend/dist/

View File

@@ -92,6 +92,7 @@ Für Portainer-Deployments ist `docker-compose.yml` als Git-basierter Stack vorb
| `AUTH_TOKEN_EXPIRES_IN_HOURS` | `12` | Lebensdauer der Tokens. | | `AUTH_TOKEN_EXPIRES_IN_HOURS` | `12` | Lebensdauer der Tokens. |
| `PAPERLESS_BASE_URL` | *(leer)* | API-URL deiner paperless-ngx Instanz. | | `PAPERLESS_BASE_URL` | *(leer)* | API-URL deiner paperless-ngx Instanz. |
| `PAPERLESS_EXTERNAL_URL` | *(leer)* | Optionale externe URL für Direktlinks im Browser. | | `PAPERLESS_EXTERNAL_URL` | *(leer)* | Optionale externe URL für Direktlinks im Browser. |
| `PAPERLESS_WEBHOOK_URL` | *(leer)* | Optionale Ziel-URL für den automatisch angelegten Paperless-Webhook. Sinnvoll für interne URLs wie `http://host:8112/integrations/paperless/webhook`, wenn der externe Proxy eingeschränkt ist. |
| `APP_EXTERNAL_URL` | *(leer)* | Optionale externe URL der App (z.B. für Links im iCal-Feed), überschreibt den Host der eingehenden Anfrage. | | `APP_EXTERNAL_URL` | *(leer)* | Optionale externe URL der App (z.B. für Links im iCal-Feed), überschreibt den Host der eingehenden Anfrage. |
| `APP_LOCALE` | `de` | Sprache für systemseitige Nachrichten (z.B. ntfy, E-Mail-Betreff); derzeit `de` oder `en`. | | `APP_LOCALE` | `de` | Sprache für systemseitige Nachrichten (z.B. ntfy, E-Mail-Betreff); derzeit `de` oder `en`. |
| `PAPERLESS_TOKEN` | *(leer)* | API-Token aus paperless-ngx. | | `PAPERLESS_TOKEN` | *(leer)* | API-Token aus paperless-ngx. |

View File

@@ -6,6 +6,7 @@ export interface ServerConfig {
databasePath: string; databasePath: string;
paperlessBaseUrl: string | null; paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null; paperlessExternalUrl: string | null;
paperlessWebhookUrl: string | null;
appExternalUrl: string | null; appExternalUrl: string | null;
appLocale: string; appLocale: string;
paperlessConfigured: boolean; paperlessConfigured: boolean;
@@ -32,6 +33,7 @@ export interface SettingsResponse {
values: { values: {
paperlessBaseUrl: string | null; paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null; paperlessExternalUrl: string | null;
paperlessWebhookUrl: string | null;
appExternalUrl: string | null; appExternalUrl: string | null;
appLocale: string; appLocale: string;
schedulerIntervalMinutes: number; schedulerIntervalMinutes: number;
@@ -69,6 +71,7 @@ export interface SettingsResponse {
export type UpdateSettingsPayload = Partial<{ export type UpdateSettingsPayload = Partial<{
paperlessBaseUrl: string | null; paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null; paperlessExternalUrl: string | null;
paperlessWebhookUrl: string | null;
appExternalUrl: string | null; appExternalUrl: string | null;
appLocale: string; appLocale: string;
paperlessToken: string | null; paperlessToken: string | null;

View File

@@ -203,12 +203,15 @@
"webhookSetupRunning": "Konfiguriere Paperless…", "webhookSetupRunning": "Konfiguriere Paperless…",
"webhookSetupSuccess": "Paperless-Workflow konfiguriert (ID {{id}})", "webhookSetupSuccess": "Paperless-Workflow konfiguriert (ID {{id}})",
"webhookSetupError": "Paperless-Workflow konnte nicht konfiguriert werden", "webhookSetupError": "Paperless-Workflow konnte nicht konfiguriert werden",
"webhookSetupHelp": "Legt ein Secret an und erstellt oder aktualisiert in Paperless den Workflow \"Contract Companion AI Review\".", "webhookSetupHelp": "Legt ein Secret an und erstellt oder aktualisiert in Paperless den Workflow \"Contract Companion AI Review\". Wenn eine Webhook-Ziel-URL gesetzt ist, nutzt Paperless diese URL.",
"webhookWorkflowInfo": "Paperless-Workflow ID {{id}} ist verknüpft.", "webhookWorkflowInfo": "Paperless-Workflow ID {{id}} ist verknüpft.",
"ical": "iCal-Abo", "ical": "iCal-Abo",
"icalFeedUrl": "Feed-URL", "icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)", "paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
"paperlessWebhookUrl": "Paperless Webhook-Ziel-URL",
"paperlessWebhookUrlExample": "http://192.168.178.33:8112/integrations/paperless/webhook",
"paperlessWebhookUrlHelp": "URL, die Paperless beim Workflow aufruft. Nutze hier bevorzugt eine interne URL zur Backend-API, wenn der externe Proxy Zugriffsbeschränkungen hat.",
"appExternalUrl": "Externe URL der App", "appExternalUrl": "Externe URL der App",
"paperlessExample": "https://paperless.example.com", "paperlessExample": "https://paperless.example.com",
"appExternalExample": "https://contracts.example.com", "appExternalExample": "https://contracts.example.com",

View File

@@ -203,12 +203,15 @@
"webhookSetupRunning": "Configuring Paperless…", "webhookSetupRunning": "Configuring Paperless…",
"webhookSetupSuccess": "Paperless workflow configured (ID {{id}})", "webhookSetupSuccess": "Paperless workflow configured (ID {{id}})",
"webhookSetupError": "Unable to configure Paperless workflow", "webhookSetupError": "Unable to configure Paperless workflow",
"webhookSetupHelp": "Creates a secret and creates or updates the Paperless workflow \"Contract Companion AI Review\".", "webhookSetupHelp": "Creates a secret and creates or updates the Paperless workflow \"Contract Companion AI Review\". If a webhook target URL is set, Paperless uses that URL.",
"webhookWorkflowInfo": "Paperless workflow ID {{id}} is linked.", "webhookWorkflowInfo": "Paperless workflow ID {{id}} is linked.",
"ical": "iCal subscription", "ical": "iCal subscription",
"icalFeedUrl": "Feed URL", "icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless external URL (for direct link)", "paperlessExternalUrl": "Paperless external URL (for direct link)",
"paperlessWebhookUrl": "Paperless webhook target URL",
"paperlessWebhookUrlExample": "http://192.168.178.33:8112/integrations/paperless/webhook",
"paperlessWebhookUrlHelp": "URL called by the Paperless workflow. Prefer an internal backend API URL when the external proxy has access restrictions.",
"appExternalUrl": "App external URL", "appExternalUrl": "App external URL",
"paperlessExample": "https://paperless.example.com", "paperlessExample": "https://paperless.example.com",
"appExternalExample": "https://contracts.example.com", "appExternalExample": "https://contracts.example.com",

View File

@@ -59,6 +59,7 @@ interface HealthResponse {
type FormValues = { type FormValues = {
paperlessBaseUrl: string; paperlessBaseUrl: string;
paperlessExternalUrl: string; paperlessExternalUrl: string;
paperlessWebhookUrl: string;
appExternalUrl: string; appExternalUrl: string;
appLocale: string; appLocale: string;
paperlessToken: string; paperlessToken: string;
@@ -91,6 +92,7 @@ type FormValues = {
const defaultValues: FormValues = { const defaultValues: FormValues = {
paperlessBaseUrl: "", paperlessBaseUrl: "",
paperlessExternalUrl: "", paperlessExternalUrl: "",
paperlessWebhookUrl: "",
appExternalUrl: "", appExternalUrl: "",
appLocale: "de", appLocale: "de",
paperlessToken: "", paperlessToken: "",
@@ -187,6 +189,7 @@ export default function SettingsPage() {
const values: FormValues = { const values: FormValues = {
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "", paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "", paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
paperlessWebhookUrl: settingsData.values.paperlessWebhookUrl ?? "",
appExternalUrl: settingsData.values.appExternalUrl ?? "", appExternalUrl: settingsData.values.appExternalUrl ?? "",
appLocale: settingsData.values.appLocale ?? "de", appLocale: settingsData.values.appLocale ?? "de",
paperlessToken: "", paperlessToken: "",
@@ -354,6 +357,9 @@ export default function SettingsPage() {
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) { if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl); payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
} }
if (formValues.paperlessWebhookUrl !== initial.paperlessWebhookUrl) {
payload.paperlessWebhookUrl = trimOrNull(formValues.paperlessWebhookUrl);
}
if (formValues.appExternalUrl !== initial.appExternalUrl) { if (formValues.appExternalUrl !== initial.appExternalUrl) {
payload.appExternalUrl = trimOrNull(formValues.appExternalUrl); payload.appExternalUrl = trimOrNull(formValues.appExternalUrl);
} }
@@ -707,6 +713,13 @@ export default function SettingsPage() {
placeholder={t("settings.paperlessExample")} placeholder={t("settings.paperlessExample")}
fullWidth fullWidth
/> />
<TextField
label={t("settings.paperlessWebhookUrl")}
{...register("paperlessWebhookUrl")}
placeholder={t("settings.paperlessWebhookUrlExample")}
fullWidth
helperText={t("settings.paperlessWebhookUrlHelp")}
/>
<TextField <TextField
label={t("settings.appExternalUrl")} label={t("settings.appExternalUrl")}
{...register("appExternalUrl")} {...register("appExternalUrl")}

View File

@@ -9,6 +9,7 @@ const configSchema = z.object({
paperlessBaseUrl: z.string().url().optional(), paperlessBaseUrl: z.string().url().optional(),
paperlessToken: z.string().min(1).optional(), paperlessToken: z.string().min(1).optional(),
paperlessExternalUrl: z.string().url().optional(), paperlessExternalUrl: z.string().url().optional(),
paperlessWebhookUrl: z.string().url().optional(),
appExternalUrl: z.string().url().optional(), appExternalUrl: z.string().url().optional(),
appLocale: z.enum(supportedLocales).default("de"), appLocale: z.enum(supportedLocales).default("de"),
schedulerIntervalMinutes: z.coerce.number().min(5).default(60), schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
@@ -62,6 +63,7 @@ const rawConfig = {
paperlessBaseUrl: readEnv("PAPERLESS_BASE_URL"), paperlessBaseUrl: readEnv("PAPERLESS_BASE_URL"),
paperlessToken: readEnv("PAPERLESS_TOKEN"), paperlessToken: readEnv("PAPERLESS_TOKEN"),
paperlessExternalUrl: readEnv("PAPERLESS_EXTERNAL_URL"), paperlessExternalUrl: readEnv("PAPERLESS_EXTERNAL_URL"),
paperlessWebhookUrl: readEnv("PAPERLESS_WEBHOOK_URL"),
appExternalUrl: readEnv("APP_EXTERNAL_URL"), appExternalUrl: readEnv("APP_EXTERNAL_URL"),
appLocale: readEnv("APP_LOCALE"), appLocale: readEnv("APP_LOCALE"),
schedulerIntervalMinutes: readEnv("SCHEDULER_INTERVAL_MINUTES"), schedulerIntervalMinutes: readEnv("SCHEDULER_INTERVAL_MINUTES"),

View File

@@ -90,6 +90,7 @@ const loginSchema = z.object({
const settingsUpdateSchema = z.object({ const settingsUpdateSchema = z.object({
paperlessBaseUrl: z.string().url().nullable().optional(), paperlessBaseUrl: z.string().url().nullable().optional(),
paperlessExternalUrl: z.string().url().nullable().optional(), paperlessExternalUrl: z.string().url().nullable().optional(),
paperlessWebhookUrl: z.string().url().nullable().optional(),
appExternalUrl: z.string().url().nullable().optional(), appExternalUrl: z.string().url().nullable().optional(),
appLocale: z.enum(["de", "en"]).optional(), appLocale: z.enum(["de", "en"]).optional(),
paperlessToken: z.string().min(1).nullable().optional(), paperlessToken: z.string().min(1).nullable().optional(),
@@ -129,6 +130,7 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
values: { values: {
paperlessBaseUrl: runtime.paperlessBaseUrl, paperlessBaseUrl: runtime.paperlessBaseUrl,
paperlessExternalUrl: runtime.paperlessExternalUrl, paperlessExternalUrl: runtime.paperlessExternalUrl,
paperlessWebhookUrl: runtime.paperlessWebhookUrl,
appExternalUrl: runtime.appExternalUrl, appExternalUrl: runtime.appExternalUrl,
appLocale: runtime.appLocale, appLocale: runtime.appLocale,
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes, schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
@@ -393,6 +395,7 @@ app.get("/config", (_req, res) => {
databasePath: config.databasePath, databasePath: config.databasePath,
paperlessBaseUrl: runtime.paperlessBaseUrl, paperlessBaseUrl: runtime.paperlessBaseUrl,
paperlessExternalUrl: runtime.paperlessExternalUrl, paperlessExternalUrl: runtime.paperlessExternalUrl,
paperlessWebhookUrl: runtime.paperlessWebhookUrl,
appExternalUrl: runtime.appExternalUrl, appExternalUrl: runtime.appExternalUrl,
appLocale: runtime.appLocale, appLocale: runtime.appLocale,
paperlessConfigured, paperlessConfigured,
@@ -431,6 +434,9 @@ app.put("/settings", (req, res) => {
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) { if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null; update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
} }
if (Object.prototype.hasOwnProperty.call(payload, "paperlessWebhookUrl")) {
update.paperlessWebhookUrl = payload.paperlessWebhookUrl ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "appExternalUrl")) { if (Object.prototype.hasOwnProperty.call(payload, "appExternalUrl")) {
update.appExternalUrl = payload.appExternalUrl ?? null; update.appExternalUrl = payload.appExternalUrl ?? null;
} }
@@ -542,8 +548,9 @@ app.post("/settings/paperless-webhook/setup", async (req, res, next) => {
} }
const secret = runtime.paperlessWebhookSecret ?? generateSecret(32); const secret = runtime.paperlessWebhookSecret ?? generateSecret(32);
const appUrl = resolveAppBaseUrl(req, runtime); const webhookUrl = runtime.paperlessWebhookUrl
const webhookUrl = `${appUrl}/api/integrations/paperless/webhook`; ? runtime.paperlessWebhookUrl.replace(/\/$/, "")
: `${resolveAppBaseUrl(req, runtime)}/api/integrations/paperless/webhook`;
const workflow = await paperlessClient.upsertContractCompanionWorkflow(webhookUrl, secret); const workflow = await paperlessClient.upsertContractCompanionWorkflow(webhookUrl, secret);
const workflowId = typeof workflow.id === "number" ? workflow.id : null; const workflowId = typeof workflow.id === "number" ? workflow.id : null;
const updated = updateRuntimeSettings({ const updated = updateRuntimeSettings({

View File

@@ -12,6 +12,7 @@ import {
export interface RuntimeSettings { export interface RuntimeSettings {
paperlessBaseUrl: string | null; paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null; paperlessExternalUrl: string | null;
paperlessWebhookUrl: string | null;
appExternalUrl: string | null; appExternalUrl: string | null;
appLocale: string; appLocale: string;
paperlessToken: string | null; paperlessToken: string | null;
@@ -123,6 +124,7 @@ export function getRuntimeSettings(): RuntimeSettings {
return { return {
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null), paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null), paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
paperlessWebhookUrl: coerceString(stored.paperlessWebhookUrl, config.paperlessWebhookUrl ?? null),
appExternalUrl: coerceString(stored.appExternalUrl, config.appExternalUrl ?? null), appExternalUrl: coerceString(stored.appExternalUrl, config.appExternalUrl ?? null),
appLocale: normalizeLocale(stored.appLocale, config.appLocale), appLocale: normalizeLocale(stored.appLocale, config.appLocale),
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null), paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),

View File

@@ -10,6 +10,7 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
export type SettingKey = export type SettingKey =
| "paperlessBaseUrl" | "paperlessBaseUrl"
| "paperlessExternalUrl" | "paperlessExternalUrl"
| "paperlessWebhookUrl"
| "appExternalUrl" | "appExternalUrl"
| "appLocale" | "appLocale"
| "paperlessToken" | "paperlessToken"