Files
TREK/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
T
Maurice 7237a1881d fix(docsync): switches that did not switch off, a webhook nobody could authenticate, and a counter that only grew
More from the audit, each checked against the code before it was touched.

The webhook obeyed neither the Documents addon nor the app_settings kill
switch, so "switch document sync off" stopped the poll while every provider
holding a webhook carried on driving full runs. Both are now read on arrival
and again when the debounce timer fires, so a switch thrown mid-window still
takes effect.

Nextcloud's subscription registered its secret under `X-TREK-Docsync-Signature`
while the endpoint reads `x-trek-docsync-secret`: the subscription existed, the
calls arrived, and every one of them was dropped for carrying no secret the
controller could find. The two names now agree, and the test pins the one the
endpoint actually reads.

A WebDAV create was an unconditional PUT, so a name already taken upstream was
silently overwritten — two trips bound to one folder, or a `receipt.pdf` a
person put there by hand, is enough. Creates now send `If-None-Match: *`.

The per-item retry bookkeeping was stuck at both ends: the backoff was computed
at step 1 every time, so three of the curve's four steps were unreachable, and
the attempt counter only ever grew, which shelved a row that failed once a
month after six months of otherwise healthy syncing. A successful pass now
clears it, which is what makes the limit mean six failures in a row.

Also: a manual run on an orphaned binding is refused with 409 (its credential
belongs to somebody no longer on the trip, and the scheduler already refuses
it, so the button was a way around that); a document deleted while a run is in
flight is no longer uploaded anyway; a path-shaped WebDAV id containing `..` is
rejected instead of addressing a file outside the bound folder; and Papra's
multipart part header no longer takes an unescaped media type, which a CR could
end early.

Client: three writes in useDocSync swallowed their failure into `void` call
sites, a load failure blanked the panel to "no providers available" whatever
the cause, and overlapping loads let the slowest response win. Writes now
report, only 403/404 blanks the panel, and a generation stamp keeps the newest
answer. The dead lastRun state went with them.
2026-09-19 00:31:13 +02:00

152 lines
6.7 KiB
TypeScript

import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { SETTING_SYNC_ENABLED, WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
/**
* `/api/docsync/webhook/:token` — the one endpoint a provider calls.
*
* Deliberately the thinnest thing in the domain. It answers 200 and schedules a
* run; it never reads the body as truth. Every provider here has a different
* payload, none of them signs it in a way all five share, Paperless gives its
* webhook five seconds before it retries, and Nextcloud's payload carries a
* path that is different for every member of a share. Treating any of that as
* data would mean trusting an unauthenticated stranger's description of what
* changed. So the webhook means exactly one thing: look now.
*
* `@Public` because a provider cannot hold a TREK session. The token in the URL
* is the authentication, one per binding, so a leaked URL can only ever nudge
* the one trip it belongs to — and nudging is all it can do. Where the provider
* supports it, a shared secret is checked as well.
*/
@Controller('api/docsync/webhook')
export class DocSyncWebhookController implements OnModuleDestroy {
/**
* One pending run per binding, so a burst folds into a single pass.
*
* Providers fire per document: dropping twenty files into a watched Nextcloud
* folder is twenty calls within a second or two. Each one used to start its
* own run, which the service's in-flight guard then answered with `busy` —
* so nineteen changes were announced and thrown away, and the one run that
* did start had begun before most of them landed. Collecting them for a beat
* and then running once is both less work and more correct.
*/
private readonly pending = new Map<number, ReturnType<typeof setTimeout>>();
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly addons: AddonsService,
private readonly db: DatabaseService,
) {}
/**
* The same two switches the scheduler obeys.
*
* Without them "switch document sync off" meant "stop the poll", while every
* provider holding a webhook kept driving full runs — an admin turning the
* addon off would have watched it carry on. Checked when the timer fires as
* well as on arrival, so a switch thrown during the debounce window still
* takes effect.
*/
private syncIsOn(): boolean {
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return false;
const killSwitch = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
)?.value;
// Unrecognised values mean ON: the setting is absent by default, and only an
// explicit 'false' stops the sync. Same rule as the job.
return killSwitch !== 'false';
}
onModuleDestroy(): void {
for (const timer of this.pending.values()) clearTimeout(timer);
this.pending.clear();
}
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
if (!this.syncIsOn()) return { received: true };
const secret = this.config.webhookSecret(link);
if (secret && !this.secretMatches(req, secret)) return { received: true };
// Fire and forget. Paperless allows five seconds before it counts the call
// as failed and retries, and a sync run takes longer than that whenever
// there is anything to do — so the answer goes out now and the run happens
// after the debounce window, by which time the rest of the burst has
// arrived and been folded into this same timer.
this.schedule(link.id, () => this.config.getLink(link.id));
return { received: true };
}
/**
* Start one run per binding per window, trailing rather than leading.
*
* Trailing on purpose: the first call of a burst is the least informed one,
* because the provider is usually still writing the rest. The link is looked
* up again when the timer fires, so a binding switched off or deleted in the
* meantime does not get one last run out of a stale row.
*/
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>): void {
if (this.pending.has(linkId)) return;
const timer = setTimeout(() => {
this.pending.delete(linkId);
const fresh = reload();
if (!fresh || fresh.sync_enabled !== 1) return;
if (!this.syncIsOn()) return;
void this.sync.syncLink(fresh);
}, WEBHOOK_NUDGE_DEBOUNCE_SECONDS * 1000);
// A pending nudge must not hold the process open at shutdown.
if (typeof timer.unref === 'function') timer.unref();
this.pending.set(linkId, timer);
}
/**
* Accept either a plain shared-secret header (Paperless workflows, Nextcloud
* `authMethod: header`) or Papra's standard-webhooks HMAC. Compared in
* constant time, and a mismatch is silently ignored rather than reported, so
* the endpoint tells a prober nothing either way.
*/
private secretMatches(req: Request, secret: string): boolean {
const header = req.get('x-trek-docsync-secret');
if (header && timingSafeEqualStr(header, secret)) return true;
const sig = req.get('webhook-signature');
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
if (sig && id && ts) {
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
const body = raw ? raw.toString('utf8') : JSON.stringify(req.body ?? {});
const expected = crypto
.createHmac('sha256', Buffer.from(secret))
.update(`${id}.${ts}.${body}`)
.digest('base64');
for (const part of sig.split(' ')) {
const value = part.startsWith('v1,') ? part.slice(3) : part;
if (timingSafeEqualStr(value, expected)) return true;
}
}
return false;
}
}
function timingSafeEqualStr(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}