Skip to content

Commit 0a8165e

Browse files
Add outgoing webhooks on file arrival
When a file lands in a user's storage, POST a JSON event ({event, at, file}) to each of their active webhooks (optionally filtered to one folder), HMAC-SHA256 signed when a secret is set. - Webhook model (+ migration); dispatch fired from the shared storeUserFile pipeline, so app uploads, the REST API and (soon) WebDAV all trigger it. Best-effort, never blocks the upload; last status/error recorded. - SSRF-guarded: target URLs are validated (no localhost/private/non-http) at create AND at send. - The app's /files upload route now goes through storeUserFile too, removing duplicated ingest logic and centralising webhook dispatch. - Account ▸ Webhooks UI: add (url/secret/folder), Test button, status/last-error, delete. FR/EN parity (450).
1 parent f5875c8 commit 0a8165e

11 files changed

Lines changed: 321 additions & 70 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CreateTable
2+
CREATE TABLE "Webhook" (
3+
"id" TEXT NOT NULL,
4+
"ownerId" TEXT NOT NULL,
5+
"url" TEXT NOT NULL,
6+
"secret" TEXT,
7+
"folderId" TEXT,
8+
"active" BOOLEAN NOT NULL DEFAULT true,
9+
"lastStatus" INTEGER,
10+
"lastError" TEXT,
11+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
13+
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
14+
);
15+
16+
-- CreateIndex
17+
CREATE INDEX "Webhook_ownerId_idx" ON "Webhook"("ownerId");
18+
19+
-- AddForeignKey
20+
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ model User {
7070
shares ShareLink[]
7171
recoveryCodes RecoveryCode[]
7272
apiTokens ApiToken[]
73+
webhooks Webhook[]
7374
7475
@@index([email])
7576
}
@@ -295,6 +296,24 @@ model ApiToken {
295296
@@index([ownerId])
296297
}
297298

299+
// An outgoing webhook: POSTed when a file lands in the owner's storage (optionally limited to
300+
// one folder). `secret` (if set) signs the body with HMAC-SHA256. lastStatus/lastError record
301+
// the most recent delivery for the UI.
302+
model Webhook {
303+
id String @id @default(cuid())
304+
ownerId String
305+
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
306+
url String
307+
secret String?
308+
folderId String?
309+
active Boolean @default(true)
310+
lastStatus Int?
311+
lastError String?
312+
createdAt DateTime @default(now())
313+
314+
@@index([ownerId])
315+
}
316+
298317
// A shareable link to a SERVER-mode file or folder. ZK targets can't be shared (the
299318
// server can't decrypt them). The opaque `token` is the URL slug.
300319
model ShareLink {

apps/api/src/lib/serialize.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
RemoteUploadJob,
1212
ShareLink,
1313
User,
14+
Webhook,
1415
} from '@prisma/client';
1516
import type {
1617
PublicApiToken,
@@ -20,8 +21,22 @@ import type {
2021
PublicRemoteJob,
2122
PublicShare,
2223
PublicUser,
24+
PublicWebhook,
2325
} from '@opencoperlock/shared';
2426

27+
export function toPublicWebhook(w: Webhook): PublicWebhook {
28+
return {
29+
id: w.id,
30+
url: w.url,
31+
hasSecret: w.secret !== null && w.secret.length > 0,
32+
folderId: w.folderId,
33+
active: w.active,
34+
lastStatus: w.lastStatus,
35+
lastError: w.lastError,
36+
createdAt: w.createdAt.toISOString(),
37+
};
38+
}
39+
2540
export function toPublicApiToken(t: ApiToken): PublicApiToken {
2641
return {
2742
id: t.id,

apps/api/src/routes/account.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,32 @@
33
* permanently delete your account. Both are scoped strictly to the requesting user.
44
*/
55
import type { FastifyPluginAsync } from 'fastify';
6-
import { passwordConfirmSchema, createQuickCodeSchema, createApiTokenSchema } from '@opencoperlock/shared';
6+
import {
7+
passwordConfirmSchema,
8+
createQuickCodeSchema,
9+
createApiTokenSchema,
10+
createWebhookSchema,
11+
assertAllowedUrl,
12+
SsrfError,
13+
} from '@opencoperlock/shared';
714
import { prisma } from '../db.js';
815
import { parseOr400 } from '../lib/validate.js';
916
import { verifyPassword, hashPassword } from '../services/password.js';
1017
import { remainingRecoveryCodes } from '../services/recovery.js';
11-
import { toPublicFile, toPublicFolder, toPublicShare, toPublicUser, toPublicQuickCode, toPublicApiToken } from '../lib/serialize.js';
18+
import {
19+
toPublicFile,
20+
toPublicFolder,
21+
toPublicShare,
22+
toPublicUser,
23+
toPublicQuickCode,
24+
toPublicApiToken,
25+
toPublicWebhook,
26+
} from '../lib/serialize.js';
1227
import { clearSessionCookie } from '../lib/cookies.js';
1328
import { audit } from '../services/audit.js';
1429
import { generateUniqueQuickCode, isCodeTakenError } from '../services/quickCode.js';
1530
import { generateToken } from '../services/apiToken.js';
31+
import { sendTestEvent } from '../services/webhooks.js';
1632

1733
export const accountRoutes: FastifyPluginAsync = async (app) => {
1834
app.addHook('preHandler', app.requireAuth);
@@ -83,6 +99,56 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
8399
return { ok: true };
84100
});
85101

102+
// ── Outgoing webhooks ────────────────────────────────────────────────────────
103+
app.get('/webhooks', async (req) => {
104+
const hooks = await prisma.webhook.findMany({ where: { ownerId: req.user!.id }, orderBy: { createdAt: 'desc' } });
105+
return { webhooks: hooks.map(toPublicWebhook) };
106+
});
107+
108+
app.post('/webhooks', async (req, reply) => {
109+
const body = parseOr400(reply, createWebhookSchema, req.body);
110+
if (!body) return;
111+
// Reject localhost / private / non-http targets up front (SSRF). Re-checked at send time too.
112+
try {
113+
assertAllowedUrl(body.url);
114+
} catch (err) {
115+
if (err instanceof SsrfError) return reply.code(400).send({ error: err.message });
116+
throw err;
117+
}
118+
if (body.folderId) {
119+
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id } });
120+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
121+
}
122+
const hook = await prisma.webhook.create({
123+
data: {
124+
ownerId: req.user!.id,
125+
url: body.url,
126+
secret: body.secret ?? null,
127+
folderId: body.folderId ?? null,
128+
},
129+
});
130+
await audit(req, 'account.webhook.create', { target: hook.id });
131+
return reply.code(201).send({ webhook: toPublicWebhook(hook) });
132+
});
133+
134+
app.post('/webhooks/:id/test', async (req, reply) => {
135+
const { id } = req.params as { id: string };
136+
const hook = await prisma.webhook.findFirst({ where: { id, ownerId: req.user!.id } });
137+
if (!hook) return reply.code(404).send({ error: 'Webhook not found' });
138+
await sendTestEvent(hook);
139+
const updated = await prisma.webhook.findUnique({ where: { id } });
140+
return { webhook: updated ? toPublicWebhook(updated) : null };
141+
});
142+
143+
app.delete('/webhooks/:id', async (req, reply) => {
144+
const { id } = req.params as { id: string };
145+
const existing = await prisma.webhook.findFirst({ where: { id, ownerId: req.user!.id } });
146+
if (!existing) return reply.code(404).send({ error: 'Webhook not found' });
147+
await prisma.webhook.delete({ where: { id } });
148+
await audit(req, 'account.webhook.delete', { target: id });
149+
return { ok: true };
150+
});
151+
86152
// ── Personal API tokens ──────────────────────────────────────────────────────
87153
app.get('/api-tokens', async (req) => {
88154
const tokens = await prisma.apiToken.findMany({

apps/api/src/routes/files.ts

Lines changed: 16 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,12 @@ import type { FastifyPluginAsync } from 'fastify';
22
import { updateFileSchema } from '@opencoperlock/shared';
33
import { prisma } from '../db.js';
44
import { parseOr400 } from '../lib/validate.js';
5-
import { newStorageKey } from '../storage/index.js';
6-
import {
7-
FileTooLargeError,
8-
InfectedFileError,
9-
ingestPlaintext,
10-
} from '../services/ingest.js';
5+
import { FileTooLargeError, InfectedFileError } from '../services/ingest.js';
6+
import { storeUserFile, QuotaExhaustedError } from '../services/upload.js';
117
import { decryptServerFile } from '../services/download.js';
128
import { trashFile } from '../services/trash.js';
13-
import {
14-
findVersionTarget,
15-
isVersionable,
16-
pruneVersions,
17-
snapshotVersion,
18-
} from '../services/versioning.js';
19-
import { adjustUsage, remainingAllowance } from '../services/quota.js';
9+
import { pruneVersions, snapshotVersion } from '../services/versioning.js';
10+
import { adjustUsage } from '../services/quota.js';
2011
import { toPublicFile } from '../lib/serialize.js';
2112
import { audit } from '../services/audit.js';
2213

@@ -52,63 +43,22 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
5243
const part = await req.file();
5344
if (!part) return reply.code(400).send({ error: 'No file provided' });
5445

55-
const allowance = await remainingAllowance(req.user!.id);
56-
if (allowance <= 0) return reply.code(413).send({ error: 'Storage quota exhausted' });
57-
58-
const storageKey = newStorageKey();
5946
try {
60-
const result = await ingestPlaintext(app.ctx, part.file, { maxBytes: allowance, storageKey });
61-
62-
// Versioning: re-uploading a text-like file under the same name keeps the old
63-
// content as a version instead of creating a duplicate row.
64-
const existing = isVersionable(part.filename, part.mimetype)
65-
? await findVersionTarget(req.user!.id, folderId ?? null, part.filename)
66-
: null;
67-
68-
if (existing) {
69-
await snapshotVersion(existing);
70-
const file = await prisma.fileObject.update({
71-
where: { id: existing.id },
72-
data: {
73-
sizeBytes: BigInt(result.sizeBytes),
74-
mimeType: part.mimetype,
75-
storageKey: result.storageKey,
76-
wrappedKey: result.wrappedKey,
77-
iv: result.iv,
78-
authTag: result.authTag,
79-
sha256: result.sha256,
80-
avStatus: result.avStatus,
81-
},
82-
});
83-
// New content adds to usage; the retained version keeps the old size.
84-
await adjustUsage(req.user!.id, result.sizeBytes);
85-
const freed = await pruneVersions(app.ctx, file.id);
86-
if (freed > 0) await adjustUsage(req.user!.id, -freed);
87-
await audit(req, 'file.version', { target: file.id });
88-
return reply.code(201).send({ file: toPublicFile(file) });
89-
}
90-
91-
const file = await prisma.fileObject.create({
92-
data: {
93-
ownerId: req.user!.id,
94-
folderId: folderId ?? null,
95-
name: part.filename,
96-
sizeBytes: BigInt(result.sizeBytes),
97-
mimeType: part.mimetype,
98-
storageKey: result.storageKey,
99-
encMode: 'SERVER',
100-
wrappedKey: result.wrappedKey,
101-
iv: result.iv,
102-
authTag: result.authTag,
103-
sha256: result.sha256,
104-
avStatus: result.avStatus,
105-
},
47+
// Shared pipeline: quota + antivirus + server-side encryption + text-file versioning,
48+
// plus outgoing-webhook dispatch — identical to the REST API and WebDAV.
49+
const { file, versioned } = await storeUserFile(app.ctx, {
50+
ownerId: req.user!.id,
51+
folderId: folderId ?? null,
52+
stream: part.file,
53+
filename: part.filename,
54+
mimetype: part.mimetype,
10655
});
107-
await adjustUsage(req.user!.id, result.sizeBytes);
108-
await audit(req, 'file.upload', { target: file.id });
56+
await audit(req, versioned ? 'file.version' : 'file.upload', { target: file.id });
10957
return reply.code(201).send({ file: toPublicFile(file) });
11058
} catch (err) {
111-
await app.ctx.storage.delete(storageKey).catch(() => {});
59+
if (err instanceof QuotaExhaustedError) {
60+
return reply.code(413).send({ error: 'Storage quota exhausted' });
61+
}
11262
if (err instanceof FileTooLargeError) {
11363
return reply.code(413).send({ error: 'Upload exceeds your available quota' });
11464
}

apps/api/src/services/upload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { newStorageKey } from '../storage/index.js';
1111
import { ingestPlaintext } from './ingest.js';
1212
import { adjustUsage, remainingAllowance } from './quota.js';
1313
import { findVersionTarget, isVersionable, pruneVersions, snapshotVersion } from './versioning.js';
14+
import { dispatchFileEvent } from './webhooks.js';
1415

1516
export class QuotaExhaustedError extends Error {
1617
constructor() {
@@ -62,6 +63,7 @@ export async function storeUserFile(ctx: AppContext, opts: StoreFileOpts): Promi
6263
await adjustUsage(opts.ownerId, result.sizeBytes);
6364
const freed = await pruneVersions(ctx, file.id);
6465
if (freed > 0) await adjustUsage(opts.ownerId, -freed);
66+
void dispatchFileEvent(opts.ownerId, file, 'file.updated');
6567
return { file, versioned: true };
6668
}
6769

@@ -82,6 +84,7 @@ export async function storeUserFile(ctx: AppContext, opts: StoreFileOpts): Promi
8284
},
8385
});
8486
await adjustUsage(opts.ownerId, result.sizeBytes);
87+
void dispatchFileEvent(opts.ownerId, file, 'file.created');
8588
return { file, versioned: false };
8689
} catch (err) {
8790
await ctx.storage.delete(storageKey).catch(() => {});

apps/api/src/services/webhooks.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Outgoing webhooks: when a file lands in a user's storage we POST a small JSON event to each of
3+
* their active webhooks (optionally filtered to one folder). Delivery is best-effort and never
4+
* blocks the upload; the last status/error is recorded for the UI.
5+
*
6+
* The body is signed with HMAC-SHA256 when a secret is set (header X-OpenCoperLock-Signature),
7+
* and every target URL is re-checked against the SSRF guard at send time so a webhook can't be
8+
* pointed at localhost or a private address.
9+
*/
10+
import { createHmac } from 'node:crypto';
11+
import type { FileObject, Webhook } from '@prisma/client';
12+
import { assertAllowedUrl } from '@opencoperlock/shared';
13+
import { prisma } from '../db.js';
14+
import { toPublicFile } from '../lib/serialize.js';
15+
16+
export type WebhookEvent = 'file.created' | 'file.updated';
17+
18+
const TIMEOUT_MS = 8000;
19+
20+
function sign(secret: string, body: string): string {
21+
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
22+
}
23+
24+
async function deliver(hook: Webhook, body: string): Promise<void> {
25+
try {
26+
assertAllowedUrl(hook.url); // re-validate at send time (defence in depth)
27+
const headers: Record<string, string> = {
28+
'content-type': 'application/json',
29+
'user-agent': 'OpenCoperLock-Webhook/1',
30+
};
31+
if (hook.secret) headers['x-opencoperlock-signature'] = sign(hook.secret, body);
32+
const res = await fetch(hook.url, { method: 'POST', headers, body, signal: AbortSignal.timeout(TIMEOUT_MS) });
33+
await prisma.webhook.update({
34+
where: { id: hook.id },
35+
data: { lastStatus: res.status, lastError: res.ok ? null : `HTTP ${res.status}` },
36+
});
37+
} catch (err) {
38+
await prisma.webhook
39+
.update({ where: { id: hook.id }, data: { lastStatus: 0, lastError: String(err).slice(0, 200) } })
40+
.catch(() => {});
41+
}
42+
}
43+
44+
/** Fire matching webhooks for a stored file. Fire-and-forget — callers should not await it. */
45+
export async function dispatchFileEvent(ownerId: string, file: FileObject, event: WebhookEvent): Promise<void> {
46+
const hooks = await prisma.webhook.findMany({ where: { ownerId, active: true } });
47+
const matching = hooks.filter((h) => !h.folderId || h.folderId === file.folderId);
48+
if (matching.length === 0) return;
49+
const body = JSON.stringify({ event, at: new Date().toISOString(), file: toPublicFile(file) });
50+
await Promise.all(matching.map((h) => deliver(h, body)));
51+
}
52+
53+
/** Send a one-off test ping to a single webhook (used by the "Test" button). */
54+
export async function sendTestEvent(hook: Webhook): Promise<void> {
55+
const body = JSON.stringify({ event: 'ping', at: new Date().toISOString(), message: 'OpenCoperLock test event' });
56+
await deliver(hook, body);
57+
}

0 commit comments

Comments
 (0)