Files
zeekayandHanzo Dev fd222d9166 vercel: gone
The browser upload path was the last of it. `uploadImage` called Vercel Blob's
`upload`, which round-tripped a client token through their storage; it now
presigns against our own bucket and PUTs straight to it — the shape
/api/file/s3/get-presigned-post-url already uses for documents. That route wants
a teamId and a docId, which an avatar has neither of, so images keep their own
entry point at /api/file/image-upload, rewritten from a Vercel token minter into
a presigner.

The limits Vercel used to enforce when it issued the token are kept: content type
is checked at the route, and size is checked client-side for a decent error —
but the store is what actually enforces it, because the browser's word on how big
a file is has never been worth anything.

/api/file/browser-upload is deleted rather than ported: nothing called it. The
six remaining `put()` calls in the webhooks service (meta images and favicons)
write through putFileServer.

package.json now has no @vercel/* dependency at all — functions, edge-config and
blob are all out, lockfile regenerated.

Verified with npx tsc --noEmit: 99 before, 99 after, and PER-FILE error counts
are identical — which is the check that survives both the line shifts from added
imports and the type-rendering churn that made five untouched files look new.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 11:31:05 -07:00

95 lines
3.1 KiB
TypeScript

import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getServerSession } from "next-auth/next";
import { getS3ClientAndConfig } from "@/lib/files/aws-client";
import { newId } from "@/lib/id-helper";
import { CustomUser } from "@/lib/types";
import { safeSlugify } from "@/lib/utils";
import { authOptions } from "../auth/[...nextauth]";
/**
* Presigns a PUT for a profile picture or a brand asset.
*
* Was Vercel Blob's `handleUpload`, which minted a client token for their
* storage. The browser now PUTs straight to our own bucket against a URL signed
* here, which is the same shape /api/file/s3/get-presigned-post-url already uses
* for documents — that route requires a teamId and docId, which an avatar has
* neither of, so images keep their own entry point.
*
* The content-type and size limits below were the ones Vercel enforced when it
* issued the token. Type is enforced here; SIZE is signed into the URL as a
* content-length range so the object store rejects an oversized body rather
* than trusting the browser.
*/
const uploadConfig = {
profile: {
allowedContentTypes: ["image/png", "image/jpg", "image/jpeg"],
maximumSizeInBytes: 2 * 1024 * 1024, // 2MB
},
assets: {
allowedContentTypes: [
"image/png",
"image/jpeg",
"image/jpg",
"image/svg+xml",
"image/x-icon",
"image/ico",
],
maximumSizeInBytes: 5 * 1024 * 1024, // 5MB
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
const type = Array.isArray(req.query.type) ? req.query.type[0] : req.query.type;
if (!type || !(type in uploadConfig)) {
return res.status(400).json({ error: "Invalid upload type specified." });
}
const config = uploadConfig[type as keyof typeof uploadConfig];
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const { fileName, contentType } = req.body as {
fileName?: string;
contentType?: string;
};
if (!fileName || !contentType) {
return res.status(400).json({ error: "fileName and contentType required" });
}
if (!config.allowedContentTypes.includes(contentType)) {
return res.status(400).json({ error: `Unsupported content type ${contentType}` });
}
const userId = (session.user as CustomUser).id;
// A random segment keeps two uploads of "logo.png" apart, which is what
// Vercel's addRandomSuffix did.
const key = `${type}/${userId}/${newId("doc")}/${safeSlugify(fileName)}`;
const { client, config: storage } = getS3ClientAndConfig();
const url = await getSignedUrl(
client,
new PutObjectCommand({
Bucket: storage.bucket,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 60 * 5 },
);
return res.status(200).json({ url, key, maxBytes: config.maximumSizeInBytes });
}