diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 83f9727..7e59e37 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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. }; diff --git a/apps/web/package.json b/apps/web/package.json index 0b5e8af..cedcf27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/src/app/api/guilds/[guildId]/branding/logo/route.ts b/apps/web/src/app/api/guilds/[guildId]/branding/logo/route.ts new file mode 100644 index 0000000..59da9fc --- /dev/null +++ b/apps/web/src/app/api/guilds/[guildId]/branding/logo/route.ts @@ -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 { + 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 }); +} diff --git a/apps/web/src/app/api/guilds/[guildId]/branding/route.ts b/apps/web/src/app/api/guilds/[guildId]/branding/route.ts index d3dd217..085566c 100644 --- a/apps/web/src/app/api/guilds/[guildId]/branding/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/branding/route.ts @@ -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 }> }, @@ -21,7 +22,7 @@ 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." }, @@ -29,9 +30,18 @@ export async function PATCH( ); } + // 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 }); } diff --git a/apps/web/src/app/api/guilds/[guildId]/logo/route.ts b/apps/web/src/app/api/guilds/[guildId]/logo/route.ts new file mode 100644 index 0000000..c3366d1 --- /dev/null +++ b/apps/web/src/app/api/guilds/[guildId]/logo/route.ts @@ -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", + }, + }); +} diff --git a/apps/web/src/app/dashboard/[guildId]/branding/page.tsx b/apps/web/src/app/dashboard/[guildId]/branding/page.tsx index 75bfce1..f1a4a72 100644 --- a/apps/web/src/app/dashboard/[guildId]/branding/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/branding/page.tsx @@ -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"; @@ -37,6 +38,7 @@ export default async function BrandingPage({

{t.branding.title}

+
); } diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 344e514..30dc2a5 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -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"; @@ -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 ( - +

{t.notAccepting}

); @@ -39,7 +41,7 @@ export default async function PublicFormPage({ const user = await getCurrentUser(); if (!user) { return ( - +

{t.needLogin}

+

{t.accessRestricted}

); @@ -64,7 +66,7 @@ export default async function PublicFormPage({ const nonce = (await headers()).get("x-nonce") ?? undefined; return ( - + {siteKey && (