feat: add zod-based validation for webhook and form payloads
This commit is contained in:
69
src/app.js
69
src/app.js
@@ -24,6 +24,14 @@ const {
|
||||
clearUserCookie,
|
||||
} = require("./lib/auth");
|
||||
const { FixedWindowRateLimiter } = require("./lib/rate-limit");
|
||||
const {
|
||||
XWebhookPayloadSchema,
|
||||
PolarWebhookPayloadSchema,
|
||||
LoginFormSchema,
|
||||
TopUpFormSchema,
|
||||
SimulateMentionFormSchema,
|
||||
parseOrThrow,
|
||||
} = require("./lib/validation");
|
||||
|
||||
function sanitizeReturnTo(value, fallback = "/app") {
|
||||
if (!value || typeof value !== "string") {
|
||||
@@ -41,14 +49,6 @@ function sanitizeReturnTo(value, fallback = "/app") {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw, fallback = null) {
|
||||
const parsed = Number.parseInt(String(raw || ""), 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
const engine = new XArtAudioEngine({
|
||||
creditConfig: config.credit,
|
||||
@@ -135,7 +135,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
return json(401, { error: "invalid_signature" });
|
||||
}
|
||||
|
||||
const payload = parseJSON(rawBody);
|
||||
const payload = parseOrThrow(
|
||||
XWebhookPayloadSchema,
|
||||
parseJSON(rawBody),
|
||||
"invalid_x_webhook_payload",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = engine.processMention({
|
||||
@@ -177,7 +181,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
return json(401, { error: "invalid_signature" });
|
||||
}
|
||||
|
||||
const payload = parseJSON(rawBody);
|
||||
const payload = parseOrThrow(
|
||||
PolarWebhookPayloadSchema,
|
||||
parseJSON(rawBody),
|
||||
"invalid_polar_webhook_payload",
|
||||
);
|
||||
|
||||
try {
|
||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
||||
@@ -244,19 +252,18 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
}
|
||||
|
||||
const form = parseFormUrlEncoded(rawBody);
|
||||
const requestedUserId = String(form.userId || "").trim();
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]{2,40}$/.test(requestedUserId)) {
|
||||
try {
|
||||
const login = parseOrThrow(LoginFormSchema, form);
|
||||
const nextPath = sanitizeReturnTo(login.returnTo, "/app");
|
||||
return redirect(nextPath, {
|
||||
"set-cookie": serializeUserCookie(login.userId),
|
||||
});
|
||||
} catch (error) {
|
||||
return redirect(withQuery("/login", {
|
||||
returnTo: sanitizeReturnTo(form.returnTo, "/app"),
|
||||
error: "Username must be 2-40 characters using letters, numbers, _ or -",
|
||||
error: error.message,
|
||||
}));
|
||||
}
|
||||
|
||||
const nextPath = sanitizeReturnTo(form.returnTo, "/app");
|
||||
return redirect(nextPath, {
|
||||
"set-cookie": serializeUserCookie(requestedUserId),
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/auth/logout") {
|
||||
@@ -291,8 +298,10 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
}
|
||||
|
||||
const form = parseFormUrlEncoded(rawBody);
|
||||
const amount = parsePositiveInt(form.amount, null);
|
||||
if (!amount || amount > 500) {
|
||||
let amount;
|
||||
try {
|
||||
amount = parseOrThrow(TopUpFormSchema, form, "Invalid credit amount").amount;
|
||||
} catch {
|
||||
return redirect(withQuery("/app", { flash: "Invalid credit amount" }));
|
||||
}
|
||||
|
||||
@@ -313,11 +322,15 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
}
|
||||
|
||||
const form = parseFormUrlEncoded(rawBody);
|
||||
const title = String(form.title || "").trim();
|
||||
const body = String(form.body || "").trim();
|
||||
|
||||
if (!title || !body) {
|
||||
return redirect(withQuery("/app", { flash: "Title and body are required" }));
|
||||
let mentionInput;
|
||||
try {
|
||||
mentionInput = parseOrThrow(
|
||||
SimulateMentionFormSchema,
|
||||
form,
|
||||
"Title and body are required",
|
||||
);
|
||||
} catch (error) {
|
||||
return redirect(withQuery("/app", { flash: error.message }));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -329,8 +342,8 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
||||
authorId: userId,
|
||||
article: {
|
||||
id: `manual-article:${randomUUID()}`,
|
||||
title,
|
||||
body,
|
||||
title: mentionInput.title,
|
||||
body: mentionInput.body,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
49
src/lib/validation.js
Normal file
49
src/lib/validation.js
Normal file
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
const { z } = require("zod");
|
||||
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{2,40}$/;
|
||||
|
||||
const XWebhookPayloadSchema = z.object({
|
||||
mentionPostId: z.string().min(1),
|
||||
callerUserId: z.string().min(1),
|
||||
parentPost: z.record(z.string(), z.unknown()).or(z.object({}).passthrough()),
|
||||
});
|
||||
|
||||
const PolarWebhookPayloadSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
credits: z.coerce.number().int().positive(),
|
||||
eventId: z.string().min(1),
|
||||
});
|
||||
|
||||
const LoginFormSchema = z.object({
|
||||
userId: z.string().regex(usernameRegex, "Username must be 2-40 characters using letters, numbers, _ or -"),
|
||||
returnTo: z.string().optional(),
|
||||
});
|
||||
|
||||
const TopUpFormSchema = z.object({
|
||||
amount: z.coerce.number().int().positive().max(500),
|
||||
});
|
||||
|
||||
const SimulateMentionFormSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
body: z.string().trim().min(1).max(120000),
|
||||
});
|
||||
|
||||
function parseOrThrow(schema, payload, errorMessage) {
|
||||
const result = schema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
const message = errorMessage || result.error.issues[0].message || "validation_failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
XWebhookPayloadSchema,
|
||||
PolarWebhookPayloadSchema,
|
||||
LoginFormSchema,
|
||||
TopUpFormSchema,
|
||||
SimulateMentionFormSchema,
|
||||
parseOrThrow,
|
||||
};
|
||||
Reference in New Issue
Block a user