Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ function buildMessage(row: PendingRow): {
return { embeds: [embed], components: [button] };
}

/**
* Try to deliver one notification. Returns true when the row should be marked
* read — on success, when there's no recipient, or on a permanent "can't DM"
* error. Returns false for transient failures so the next tick retries.
*/
/**
* Post the "new submission" review embed to the guild's configured review
* channel. Drops (marks read) when there's no guild, no configured channel, or
Expand Down Expand Up @@ -128,6 +123,11 @@ async function deliverReview(client: Client, row: PendingRow): Promise<boolean>
}
}

/**
* Try to deliver one notification. Returns true when the row should be marked
* read — on success, when there's no recipient, or on a permanent "can't DM"
* error. Returns false for transient failures so the next tick retries.
*/
async function deliverOne(client: Client, row: PendingRow): Promise<boolean> {
if (row.type === "submission_review") return deliverReview(client, row);

Expand Down Expand Up @@ -172,14 +172,22 @@ export async function deliverPendingNotifications(client: Client): Promise<void>
},
})) as PendingRow[];

// Deliver each row independently (one failure must not abort the batch),
// collect the ones to retire, then mark them all read in a single write.
const deliveredIds: string[] = [];
for (const row of pending) {
if (await deliverOne(client, row)) {
await prisma.notification.update({
where: { id: row.id },
data: { readAt: new Date() },
});
try {
if (await deliverOne(client, row)) deliveredIds.push(row.id);
} catch (err) {
console.error(`[bot] delivery failed for notification ${row.id}:`, err);
}
}
if (deliveredIds.length > 0) {
await prisma.notification.updateMany({
where: { id: { in: deliveredIds } },
data: { readAt: new Date() },
});
}
} catch (err) {
console.error("[bot] notification delivery error:", err);
} finally {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/files/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export async function GET(
"Cache-Control": "private, max-age=0, no-store",
"X-Content-Type-Options": "nosniff",
});
if (object.contentLength) headers.set("Content-Length", String(object.contentLength));
if (object.contentLength !== undefined) {
headers.set("Content-Length", String(object.contentLength));
}

return new NextResponse(object.body, { headers });
}
9 changes: 7 additions & 2 deletions apps/web/src/app/api/forms/[slug]/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { prisma } from "@msk-forms/db";
import { FILE_FIELD_TYPES } from "@msk-forms/shared";
import { FILE_FIELD_TYPES, MAX_FILE_SIZE_MB } from "@msk-forms/shared";
import { NextResponse, type NextRequest } from "next/server";

import { parseFormSpec } from "@/lib/forms";
Expand Down Expand Up @@ -59,7 +59,10 @@ export async function POST(
return NextResponse.json({ error: "Not a file field." }, { status: 400 });
}

const maxBytes = (field.validation.maxFileSizeMb ?? DEFAULT_MAX_FILE_MB) * 1024 * 1024;
// Clamp the field's configured limit to the hard server ceiling (defense in
// depth — the spec schema also caps it, but never trust a stored value).
const limitMb = Math.min(field.validation.maxFileSizeMb ?? DEFAULT_MAX_FILE_MB, MAX_FILE_SIZE_MB);
const maxBytes = limitMb * 1024 * 1024;
if (file.size === 0 || file.size > maxBytes) {
return NextResponse.json(
{ error: `File must be between 1 byte and ${Math.round(maxBytes / 1024 / 1024)} MB.` },
Expand All @@ -80,6 +83,8 @@ export async function POST(

const key = `uploads/${form.id}/${crypto.randomUUID()}`;
try {
// Buffer fully into memory — bounded by the size check above (≤ MAX_FILE_SIZE_MB),
// so this is safe; streaming would only matter for much larger uploads.
await putObject(key, new Uint8Array(await file.arrayBuffer()), mime);
} catch (err) {
console.error("[upload] storage error:", (err as Error).message);
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/app/api/submissions/[id]/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { prisma } from "@msk-forms/db";
import { NextResponse, type NextRequest } from "next/server";

import { clientIp, rateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

Expand All @@ -9,10 +11,15 @@ export const dynamic = "force-dynamic";
* Capability model: access by the submission UUID.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// Throttle for parity with withdraw/delete (the UUID is the only credential).
const rl = await rateLimit(`export:${clientIp(request.headers)}`, 10, 60);
if (!rl.allowed) {
return NextResponse.json({ error: "Too many requests." }, { status: 429 });
}

const submission = await prisma.submission.findUnique({
where: { id },
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/api/submissions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ export async function DELETE(
});
if (!submission) return NextResponse.json({ error: "Not found." }, { status: 404 });

// Remove stored files first (best-effort), then the row (cascades events/files).
await Promise.all(submission.files.map((f) => deleteObject(f.storageKey)));
// Delete the row first (cascades events + file rows) so the erasure is durable
// even if object storage is down; then best-effort purge the stored objects.
// The storage keys were captured above before the row is gone.
const keys = submission.files.map((f) => f.storageKey);
await prisma.submission.delete({ where: { id } });
await Promise.all(keys.map((key) => deleteObject(key)));

return NextResponse.json({ ok: true });
}
46 changes: 30 additions & 16 deletions apps/web/src/app/api/submissions/[id]/withdraw/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,44 @@ export async function POST(
return NextResponse.json({ error: "Too many requests." }, { status: 429 });
}

const submission = await prisma.submission.findUnique({
where: { id },
select: { status: true },
});
if (!submission) return NextResponse.json({ error: "Not found." }, { status: 404 });
// Read + guarded write in one transaction so two concurrent withdraws (or a
// withdraw racing a reviewer decision) can't both record the change. The
// `updateMany ... status = current` only succeeds while the status is still
// what we read; terminal statuses (incl. an existing withdrawal) are rejected.
const outcome = await prisma.$transaction(async (tx) => {
const submission = await tx.submission.findUnique({
where: { id },
select: { status: true },
});
if (!submission) return { code: 404 as const };
if (TERMINAL.has(submission.status)) return { code: 409 as const };

if (TERMINAL.has(submission.status)) {
return NextResponse.json(
{ error: "This submission can no longer be withdrawn." },
{ status: 409 },
);
}
const updated = await tx.submission.updateMany({
where: { id, status: submission.status },
data: { status: "withdrawn" },
});
if (updated.count === 0) return { code: 409 as const };

await prisma.$transaction([
prisma.submission.update({ where: { id }, data: { status: "withdrawn" } }),
prisma.submissionEvent.create({
await tx.submissionEvent.create({
data: {
submissionId: id,
type: "status_change",
fromStatus: submission.status,
toStatus: "withdrawn",
visibility: "public",
},
}),
]);
});
return { code: 200 as const };
});

if (outcome.code === 404) {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
if (outcome.code === 409) {
return NextResponse.json(
{ error: "This submission can no longer be withdrawn." },
{ status: 409 },
);
}
return NextResponse.json({ ok: true });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- The outbox poller scans for undelivered rows ordered by creation time:
-- WHERE read_at IS NULL ORDER BY created_at ASC
-- A partial index over just the unread rows keeps that scan cheap even as the
-- delivered-notification table grows. (Partial indexes aren't expressible in the
-- Prisma schema, so this is a hand-written migration.)
CREATE INDEX IF NOT EXISTS "notifications_pending_idx"
ON "notifications" ("created_at")
WHERE "read_at" IS NULL;
6 changes: 5 additions & 1 deletion packages/shared/src/form-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export const conditionRuleSchema = z.object({
target: z.string().optional(),
});

/** Absolute server-side ceiling for a single upload, regardless of field config. */
export const MAX_FILE_SIZE_MB = 25;

export const fieldValidationSchema = z.object({
required: z.boolean().default(false),
min: z.number().optional(),
Expand All @@ -84,7 +87,8 @@ export const fieldValidationSchema = z.object({
maxLength: z.number().optional(),
pattern: z.string().optional(),
allowedMimeTypes: z.array(z.string()).optional(),
maxFileSizeMb: z.number().optional(),
// Capped at the hard server limit so a crafted spec can't raise it.
maxFileSizeMb: z.number().positive().max(MAX_FILE_SIZE_MB).optional(),
});

export const fieldOptionSchema = z.object({
Expand Down