aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 22:43:24 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-14 22:43:24 -0800
commitcdb1a57614068fcfefa145bc6df45c9def7ccc6a (patch)
tree92cadbecda8658c143b7625d5925e3411976a892 /src
parent6d318665a08c0d4564d8de23cc39425d2c0bac49 (diff)
downloadposthook-cdb1a57614068fcfefa145bc6df45c9def7ccc6a.tar.gz
posthook-cdb1a57614068fcfefa145bc6df45c9def7ccc6a.zip
Updates
Diffstat (limited to 'src')
-rw-r--r--src/activity/index.ts146
-rw-r--r--src/storage/index.ts79
-rw-r--r--src/types/index.ts2
3 files changed, 192 insertions, 35 deletions
diff --git a/src/activity/index.ts b/src/activity/index.ts
index 20d123c..c3ee821 100644
--- a/src/activity/index.ts
+++ b/src/activity/index.ts
@@ -110,25 +110,58 @@ export class WebhookActivityImpl implements IWebhookActivity {
private async parseBody(
req: PenguenoRequest,
contentType: ContentType,
- ): Promise<IEither<PenguenoError, { body: unknown; redirect: string | undefined; token: string | undefined }>> {
+ ): Promise<
+ IEither<
+ PenguenoError,
+ {
+ body: unknown;
+ redirect: string | undefined;
+ token: string | undefined;
+ uploads:
+ | Array<{
+ fieldName: string;
+ filename: string;
+ contentType: string;
+ size: number;
+ data: Uint8Array;
+ }>
+ | undefined;
+ }
+ >
+ > {
try {
- const rawBody = await req.req.text();
-
- type ParsedBody = { body: unknown; redirect: string | undefined; token: string | undefined };
+ type ParsedBody = {
+ body: unknown;
+ redirect: string | undefined;
+ token: string | undefined;
+ uploads:
+ | Array<{
+ fieldName: string;
+ filename: string;
+ contentType: string;
+ size: number;
+ data: Uint8Array;
+ }>
+ | undefined;
+ };
switch (contentType) {
- case ContentType.JSON:
+ case ContentType.JSON: {
+ const rawBody = await req.req.text();
try {
return Either.right(<ParsedBody>{
body: JSON.parse(rawBody),
redirect: undefined,
token: undefined,
+ uploads: undefined,
});
} catch {
return Either.left(new PenguenoError('Invalid JSON', 400));
}
+ }
- case ContentType.FORM:
+ case ContentType.FORM: {
+ const rawBody = await req.req.text();
try {
const formData = new URLSearchParams(rawBody);
const obj: Record<string, string> = {};
@@ -144,22 +177,95 @@ export class WebhookActivityImpl implements IWebhookActivity {
obj[key] = value;
}
}
- return Either.right(<ParsedBody>{ body: obj, redirect, token });
+ return Either.right(<ParsedBody>{ body: obj, redirect, token, uploads: undefined });
} catch {
return Either.left(new PenguenoError('Invalid form data', 400));
}
+ }
+
+ case ContentType.MULTIPART: {
+ try {
+ const formData = await req.req.formData();
+ const obj: Record<string, string | string[]> = {};
+ const uploads: ParsedBody['uploads'] = [];
+ let redirect: string | undefined;
+ let token: string | undefined;
- case ContentType.TEXT:
- return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+ for (const [key, value] of formData.entries()) {
+ if (typeof value === 'string') {
+ if (key === '_redirect') {
+ redirect = value;
+ } else if (key === '_token') {
+ token = value;
+ } else {
+ const existing = obj[key];
+ if (existing === undefined) {
+ obj[key] = value;
+ } else if (Array.isArray(existing)) {
+ existing.push(value);
+ } else {
+ obj[key] = [existing, value];
+ }
+ }
+ continue;
+ }
- case ContentType.RAW:
- return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+ // Avoid DOM typings; treat as a File-like object.
+ const maybeFile = value as unknown as {
+ name?: unknown;
+ type?: unknown;
+ size?: unknown;
+ arrayBuffer?: unknown;
+ };
+
+ const filename = typeof maybeFile.name === 'string' ? maybeFile.name : 'upload.bin';
+ const contentType =
+ typeof maybeFile.type === 'string' ? maybeFile.type : 'application/octet-stream';
+ const size = typeof maybeFile.size === 'number' ? maybeFile.size : 0;
+
+ if (typeof maybeFile.arrayBuffer !== 'function') {
+ return Either.left(new PenguenoError('Invalid multipart file upload', 400));
+ }
- case ContentType.MULTIPART:
- return Either.left(new PenguenoError('Multipart not yet implemented', 501));
+ const buf = new Uint8Array(await (maybeFile.arrayBuffer as () => Promise<ArrayBuffer>)());
+ uploads.push({ fieldName: key, filename, contentType, size, data: buf });
+ }
- default:
- return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+ return Either.right(<ParsedBody>{ body: obj, redirect, token, uploads });
+ } catch {
+ return Either.left(new PenguenoError('Invalid multipart form data', 400));
+ }
+ }
+
+ case ContentType.TEXT: {
+ const rawBody = await req.req.text();
+ return Either.right(<ParsedBody>{
+ body: rawBody,
+ redirect: undefined,
+ token: undefined,
+ uploads: undefined,
+ });
+ }
+
+ case ContentType.RAW: {
+ const rawBody = await req.req.text();
+ return Either.right(<ParsedBody>{
+ body: rawBody,
+ redirect: undefined,
+ token: undefined,
+ uploads: undefined,
+ });
+ }
+
+ default: {
+ const rawBody = await req.req.text();
+ return Either.right(<ParsedBody>{
+ body: rawBody,
+ redirect: undefined,
+ token: undefined,
+ uploads: undefined,
+ });
+ }
}
} catch (err) {
return Either.left(new PenguenoError(err instanceof Error ? err.message : String(err), 500));
@@ -227,7 +333,7 @@ export class WebhookActivityImpl implements IWebhookActivity {
return tReq.move(Either.left<PenguenoError, WebhookResult>(bodyResult.left().get()));
}
- const { body, redirect, token: bodyToken } = bodyResult.right().get();
+ const { body, redirect, token: bodyToken, uploads } = bodyResult.right().get();
// Validate token if required
if (route.requireToken) {
@@ -252,7 +358,7 @@ export class WebhookActivityImpl implements IWebhookActivity {
}
// Store the request
- const storeResult = await this.storage.storeRequest(routeName, req.method, headers, body);
+ const storeResult = await this.storage.storeRequest(routeName, req.method, headers, body, uploads);
if (storeResult.left().present()) {
return tReq.move(
Either.left<PenguenoError, WebhookResult>(
@@ -274,11 +380,13 @@ export class WebhookActivityImpl implements IWebhookActivity {
}
}
- const filename = `${storedRequest.timestamp}_${storedRequest.uuid}.json`;
+ const baseName = `${storedRequest.timestamp}_${storedRequest.uuid}`;
+ const storedPath = `${baseName}/request.json`;
+
return tReq.move(
Either.right<PenguenoError, WebhookResult>({
success: true,
- stored: filename,
+ stored: storedPath,
redirect,
}),
);
diff --git a/src/storage/index.ts b/src/storage/index.ts
index 2c8ffb2..631fc2e 100644
--- a/src/storage/index.ts
+++ b/src/storage/index.ts
@@ -1,9 +1,23 @@
import { randomUUID } from 'crypto';
import { mkdir, writeFile, readFile } from 'fs/promises';
-import { join } from 'path';
+import { basename, join } from 'path';
import { isSafeRouteName, type RouteConfig, type StoredRequest } from '../types/index.js';
import { Either, type IEither } from '@emprespresso/pengueno';
+type IncomingUpload = {
+ fieldName: string;
+ filename: string;
+ contentType: string;
+ size: number;
+ data: Uint8Array;
+};
+
+function sanitizeFilename(filename: string): string {
+ const base = basename(filename);
+ const safe = base.replace(/[^a-zA-Z0-9._-]/g, '_');
+ return safe.length > 0 ? safe.slice(0, 200) : 'upload.bin';
+}
+
export class Storage {
private routes: Map<string, RouteConfig> = new Map();
@@ -83,7 +97,7 @@ export class Storage {
method: string,
headers: Record<string, string>,
body: unknown,
- files?: StoredRequest['files'],
+ uploads?: IncomingUpload[],
): Promise<IEither<Error, StoredRequest>> {
if (!isSafeRouteName(routeName)) {
return Either.left(new Error('Invalid route name'));
@@ -91,21 +105,54 @@ export class Storage {
const timestamp = Date.now();
const uuid = randomUUID();
- const filename = `${timestamp}_${uuid}.json`;
-
- const stored: StoredRequest = {
- timestamp,
- uuid,
- routeName,
- method,
- headers,
- body,
- files,
- };
-
- const filepath = join(this.dataDir, routeName, filename);
+ const baseName = `${timestamp}_${uuid}`;
+ const routeDir = join(this.dataDir, routeName);
+
try {
- await writeFile(filepath, JSON.stringify(stored, null, 2));
+ await mkdir(routeDir, { recursive: true });
+
+ const requestDir = join(routeDir, baseName);
+ await mkdir(requestDir, { recursive: true });
+
+ const files: StoredRequest['files'] = uploads?.length
+ ? await (async () => {
+ const filesDir = join(requestDir, 'files');
+ await mkdir(filesDir, { recursive: true });
+
+ const storedFiles: NonNullable<StoredRequest['files']> = [];
+ for (let i = 0; i < uploads.length; i++) {
+ const upload = uploads[i];
+ const safeOriginal = sanitizeFilename(upload.filename);
+ const savedName = `${i}_${safeOriginal}`;
+ const diskPath = join(filesDir, savedName);
+ await writeFile(diskPath, Buffer.from(upload.data));
+
+ storedFiles.push({
+ fieldName: upload.fieldName,
+ originalFilename: upload.filename,
+ filename: savedName,
+ contentType: upload.contentType,
+ size: upload.size,
+ path: join('files', savedName),
+ });
+ }
+
+ return storedFiles;
+ })()
+ : undefined;
+
+ const stored: StoredRequest = {
+ timestamp,
+ uuid,
+ routeName,
+ method,
+ headers,
+ body,
+ files,
+ };
+
+ await writeFile(join(requestDir, 'request.json'), JSON.stringify(stored, null, 2));
+ await writeFile(join(requestDir, 'body.json'), JSON.stringify(body, null, 2));
return Either.right(stored);
} catch (err) {
return Either.left(err instanceof Error ? err : new Error(String(err)));
diff --git a/src/types/index.ts b/src/types/index.ts
index fbfc70d..5e0b2d4 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -29,6 +29,8 @@ export interface StoredRequest {
headers: Record<string, string>;
body: unknown;
files?: Array<{
+ fieldName: string;
+ originalFilename: string;
filename: string;
contentType: string;
size: number;