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
2 changes: 2 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const nextConfig: NextConfig = {
transpilePackages: ["@msk-forms/ui", "@msk-forms/shared", "@msk-forms/db"],
// typedRoutes graduated out of `experimental` in Next.js 16.
typedRoutes: true,
// sharp is a native module (logo re-encoding) — keep it external, don't bundle.
serverExternalPackages: ["sharp"],
// Note: Next.js 16 removed `next lint` — linting runs centrally via the
// flat config (eslint.config.mjs) at the monorepo root.
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.35.2",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/app/api/guilds/[guildId]/branding/logo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Prisma, prisma } from "@msk-forms/db";
import { type Branding } from "@msk-forms/shared";
import { NextResponse, type NextRequest } from "next/server";
import sharp from "sharp";

import { getCurrentUser } from "@/lib/auth";
import { parseBranding } from "@/lib/branding";
import { canManageForms } from "@/lib/guild";
import { sniffRasterImage } from "@/lib/image";
import { deleteObject, putObject, s3Enabled } from "@/lib/s3";

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

const MAX_BYTES = 1024 * 1024; // 1 MB
const MAX_DIM = 512;

async function loadBranding(guildId: string): Promise<Branding> {
const guild = await prisma.guild.findUnique({ where: { id: guildId }, select: { branding: true } });
return parseBranding(guild?.branding);
}

/** Upload a guild logo. Manager-only, hardened against malicious uploads. */
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
) {
const { guildId } = await params;

const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
if (!(await canManageForms(guildId, user.id))) {
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}
if (!s3Enabled()) {
return NextResponse.json({ error: "File storage is not available." }, { status: 503 });
}

const formData = await request.formData().catch(() => null);
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file." }, { status: 400 });
}
if (file.size === 0 || file.size > MAX_BYTES) {
return NextResponse.json({ error: "Logo must be 1 byte–1 MB." }, { status: 413 });
}

const input = new Uint8Array(await file.arrayBuffer());

// 1) Reject anything that isn't a known raster image by its real bytes
// (no SVG → no script; ignore the client-declared MIME entirely).
if (!sniffRasterImage(input)) {
return NextResponse.json(
{ error: "Only PNG, JPEG, WebP or GIF images are allowed." },
{ status: 415 },
);
}

// 2) Decode + re-encode with sharp: this strips any hidden payload, EXIF,
// color profiles, or polyglot tricks — only pixels survive. Bounded
// input pixels guard against decompression bombs.
let webp: Buffer;
try {
webp = await sharp(input, { limitInputPixels: 24_000_000, failOn: "error" })
.rotate()
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 88 })
.toBuffer();
} catch {
return NextResponse.json({ error: "That image couldn't be processed." }, { status: 422 });
}

const branding = await loadBranding(guildId);
const key = `branding/${guildId}/${crypto.randomUUID()}.webp`;
try {
await putObject(key, new Uint8Array(webp), "image/webp");
} catch (err) {
console.error("[logo] storage error:", (err as Error).message);
return NextResponse.json({ error: "Upload failed. Please try again." }, { status: 502 });
}

// Swap in the new key, then best-effort remove the old object.
const previous = branding.logoKey;
await prisma.guild.update({
where: { id: guildId },
data: { branding: { ...branding, logoKey: key } as Prisma.InputJsonValue },
});
if (previous && previous !== key) await deleteObject(previous);

return NextResponse.json({ ok: true });
}

/** Remove a guild logo. Manager-only. */
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
) {
const { guildId } = await params;

const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
if (!(await canManageForms(guildId, user.id))) {
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}

const branding = await loadBranding(guildId);
if (branding.logoKey) {
const { logoKey, ...rest } = branding;
void logoKey;
await prisma.guild.update({
where: { id: guildId },
data: { branding: rest as Prisma.InputJsonValue },
});
await deleteObject(branding.logoKey);
}
return NextResponse.json({ ok: true });
}
18 changes: 14 additions & 4 deletions apps/web/src/app/api/guilds/[guildId]/branding/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Prisma, prisma } from "@msk-forms/db";
import { brandingSchema } from "@msk-forms/shared";
import { brandingColorSchema, type Branding } from "@msk-forms/shared";
import { NextResponse, type NextRequest } from "next/server";

import { getCurrentUser } from "@/lib/auth";
import { parseBranding } from "@/lib/branding";
import { canManageForms } from "@/lib/guild";

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

/** Update a guild's branding. Requires owner/admin. */
/** Update a guild's accent color. Owner/admin only. Preserves the logo. */
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
Expand All @@ -21,17 +22,26 @@ export async function PATCH(
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}

const parsed = brandingSchema.safeParse(await request.json().catch(() => null));
const parsed = brandingColorSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid branding." },
{ status: 422 },
);
}

// Read-merge-write so the separately-managed logo isn't clobbered.
const guild = await prisma.guild.findUnique({
where: { id: guildId },
select: { branding: true },
});
const next: Branding = { ...parseBranding(guild?.branding) };
if (parsed.data.accentColor) next.accentColor = parsed.data.accentColor;
else delete next.accentColor;

await prisma.guild.update({
where: { id: guildId },
data: { branding: parsed.data as Prisma.InputJsonValue },
data: { branding: next as Prisma.InputJsonValue },
});
return NextResponse.json({ ok: true });
}
38 changes: 38 additions & 0 deletions apps/web/src/app/api/guilds/[guildId]/logo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { prisma } from "@msk-forms/db";
import { NextResponse, type NextRequest } from "next/server";

import { parseBranding } from "@/lib/branding";
import { getObject } from "@/lib/s3";

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

/**
* Stream a guild's logo (public branding asset). Always served as image/webp
* (logos are re-encoded to WebP on upload) with nosniff, so the response can't
* be reinterpreted as another content type.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
) {
const { guildId } = await params;

const guild = await prisma.guild.findUnique({
where: { id: guildId },
select: { branding: true },
});
const key = parseBranding(guild?.branding).logoKey;
if (!key) return NextResponse.json({ error: "No logo." }, { status: 404 });

const object = await getObject(key);
if (!object) return NextResponse.json({ error: "No logo." }, { status: 404 });

return new NextResponse(object.body, {
headers: {
"Content-Type": "image/webp",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "public, max-age=300",
},
});
}
4 changes: 3 additions & 1 deletion apps/web/src/app/dashboard/[guildId]/branding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { prisma } from "@msk-forms/db";
import { Card } from "@msk-forms/ui";

import { BrandingForm } from "@/components/branding/branding-form";
import { LogoForm } from "@/components/branding/logo-form";
import { requireUser } from "@/lib/auth";
import { parseBranding } from "@/lib/branding";
import { logoUrl, parseBranding } from "@/lib/branding";
import { canManageForms } from "@/lib/guild";
import { getDict } from "@/i18n";

Expand Down Expand Up @@ -37,6 +38,7 @@ export default async function BrandingPage({
<div className="flex flex-col gap-4">
<h2 className="font-heading text-xl font-semibold text-foreground">{t.branding.title}</h2>
<BrandingForm guildId={guildId} initial={branding} t={t.branding} />
<LogoForm guildId={guildId} logoUrl={logoUrl(guildId, branding)} t={t.branding} />
</div>
);
}
17 changes: 11 additions & 6 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { CSSProperties } from "react";

import { FormRenderer } from "@/components/form/form-renderer";
import { getCurrentUser } from "@/lib/auth";
import { brandStyle, parseBranding } from "@/lib/branding";
import { brandStyle, logoUrl, parseBranding } from "@/lib/branding";
import { captchaSiteKey } from "@/lib/captcha";
import { getLiveFormBySlug } from "@/lib/forms";
import { getDict } from "@/i18n";
Expand All @@ -25,11 +25,13 @@ export default async function PublicFormPage({

if (!form || !form.spec) notFound();

const brand = brandStyle(parseBranding(form.guild.branding));
const branding = parseBranding(form.guild.branding);
const brand = brandStyle(branding);
const logo = logoUrl(form.guildId, branding);

if (form.status !== "live") {
return (
<Shell guildName={form.guild.name} title={form.title} style={brand}>
<Shell guildName={form.guild.name} title={form.title} style={brand} logoSrc={logo}>
<p className="text-sm text-muted-foreground">{t.notAccepting}</p>
</Shell>
);
Expand All @@ -39,7 +41,7 @@ export default async function PublicFormPage({
const user = await getCurrentUser();
if (!user) {
return (
<Shell guildName={form.guild.name} title={form.title} style={brand}>
<Shell guildName={form.guild.name} title={form.title} style={brand} logoSrc={logo}>
<p className="text-sm text-muted-foreground">{t.needLogin}</p>
<a
href={`/api/auth/discord/login?returnTo=/f/${slug}`}
Expand All @@ -54,7 +56,7 @@ export default async function PublicFormPage({

if (form.visibility === "password" || form.visibility === "role_required") {
return (
<Shell guildName={form.guild.name} title={form.title} style={brand}>
<Shell guildName={form.guild.name} title={form.title} style={brand} logoSrc={logo}>
<p className="text-sm text-muted-foreground">{t.accessRestricted}</p>
</Shell>
);
Expand All @@ -64,7 +66,7 @@ export default async function PublicFormPage({
const nonce = (await headers()).get("x-nonce") ?? undefined;

return (
<Shell guildName={form.guild.name} title={form.title} description={form.description} style={brand}>
<Shell guildName={form.guild.name} title={form.title} description={form.description} style={brand} logoSrc={logo}>
{siteKey && (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
Expand Down Expand Up @@ -97,16 +99,19 @@ function Shell({
description,
children,
style,
logoSrc,
}: {
guildName: string;
title: string;
description?: string | null;
children: React.ReactNode;
style?: CSSProperties;
logoSrc?: string | null;
}) {
return (
<main className="mx-auto flex max-w-2xl flex-col gap-6 px-6 py-12" style={style}>
<header className="flex flex-col gap-1">
{logoSrc && <img src={logoSrc} alt="" className="mb-2 h-12 w-auto self-start" />}
<span className="text-sm font-medium text-primary">{guildName}</span>
<h1 className="font-heading text-3xl font-bold text-foreground">{title}</h1>
{description && <p className="text-muted-foreground">{description}</p>}
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { notFound } from "next/navigation";

import { AnswerSummary } from "@/components/submission/answer-summary";
import { SubmissionActions } from "@/components/submission/submission-actions";
import { brandStyle, parseBranding } from "@/lib/branding";
import { brandStyle, logoUrl, parseBranding } from "@/lib/branding";
import { getSubmissionForStatus, resolveStatus } from "@/lib/forms";
import { getDict } from "@/i18n";

Expand All @@ -25,11 +25,14 @@ export default async function SubmissionStatusPage({
const t = (await getDict()).status;
const status = resolveStatus(submission.status, submission.statusDefs);
const answers = (submission.answers ?? {}) as Record<string, unknown>;
const brand = brandStyle(parseBranding(submission.form.guild.branding));
const branding = parseBranding(submission.form.guild.branding);
const brand = brandStyle(branding);
const logo = logoUrl(submission.form.guildId, branding);

return (
<main className="mx-auto flex max-w-2xl flex-col gap-6 px-6 py-12" style={brand}>
<header className="flex flex-col gap-3">
{logo && <img src={logo} alt="" className="h-12 w-auto self-start" />}
<span className="text-sm font-medium text-primary">{t.yourSubmission}</span>
<h1 className="font-heading text-3xl font-bold text-foreground">
{submission.form.title}
Expand Down
Loading