63 lines
3.3 KiB
TypeScript
63 lines
3.3 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|
import sharp from "sharp";
|
|
import { getDb } from "@/db";
|
|
import { accounts, users } from "@/db/schema";
|
|
import { requireCommissionUser } from "@/lib/commission/server";
|
|
import { inspectImage } from "@/lib/media/inspect";
|
|
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
|
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
|
import { limitRequest } from "@/lib/security/rate-limit";
|
|
|
|
const MAX_PROFILE_IMAGE_BYTES = 6 * 1024 * 1024;
|
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
requireSameOrigin(request);
|
|
const user = await requireCommissionUser();
|
|
const [sudloh] = await getDb().select({ id: accounts.id }).from(accounts)
|
|
.where(and(eq(accounts.userId, user.id), eq(accounts.providerId, "sudloh"))).limit(1);
|
|
if (sudloh) throw new HttpError(403, "manage-profile-at-sudloh");
|
|
await limitRequest("profile-update", user.id, 20);
|
|
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
|
|
throw new HttpError(415, "expected-multipart");
|
|
return await withUploadSlot(async () => {
|
|
const form = await boundedBody(request, MAX_PROFILE_IMAGE_BYTES + 64 * 1024).formData();
|
|
const rawName = form.get("name");
|
|
const name = typeof rawName === "string" ? rawName.trim() : "";
|
|
if (name.length < 2 || name.length > 80) throw new HttpError(400, "invalid-display-name");
|
|
const image = form.get("image");
|
|
if (image !== null && !(image instanceof File)) throw new HttpError(400, "invalid-profile-image");
|
|
let objectKey: string | null = null;
|
|
if (image instanceof File) {
|
|
if (image.size === 0 || image.size > MAX_PROFILE_IMAGE_BYTES ||
|
|
!IMAGE_TYPES.includes(image.type as typeof IMAGE_TYPES[number]))
|
|
throw new HttpError(415, "invalid-profile-image");
|
|
const bytes = new Uint8Array(await image.arrayBuffer());
|
|
await inspectImage(bytes, image.type as typeof IMAGE_TYPES[number]);
|
|
const output = await sharp(bytes).rotate().resize(256, 256, { fit: "cover" }).webp({ quality: 82 }).toBuffer();
|
|
objectKey = `profiles/${crypto.randomUUID()}.webp`;
|
|
await (await getMediaStorage()).write(objectKey, output, { type: "image/webp", acl: "public-read" });
|
|
}
|
|
try {
|
|
const [updated] = await getDb().update(users).set({ name,
|
|
...(objectKey ? { image: publicMediaUrl(objectKey) } : {}) })
|
|
.where(eq(users.id, user.id)).returning({ name: users.name, image: users.image });
|
|
if (!updated) throw new HttpError(401, "unauthorized");
|
|
if (objectKey && user.image) {
|
|
const prefix = publicMediaUrl("profiles/");
|
|
if (user.image.startsWith(prefix)) {
|
|
const previousKey = `profiles/${user.image.slice(prefix.length)}`;
|
|
if (/^profiles\/[0-9a-f-]{36}\.webp$/u.test(previousKey))
|
|
await (await getMediaStorage()).delete(previousKey).catch(() => undefined);
|
|
}
|
|
}
|
|
return Response.json(updated);
|
|
} catch (cause) {
|
|
if (objectKey) await (await getMediaStorage()).delete(objectKey).catch(() => undefined);
|
|
throw cause;
|
|
}
|
|
});
|
|
} catch (cause) { return errorResponse(cause); }
|
|
}
|