Skip to content

Commit

Permalink
Client side file uploads (#1087)
Browse files Browse the repository at this point in the history
* refactor: use `withAuthenticatedUser` helper in file upload route

* feat: client side file uploads

Upload files to the Vercel S3 buckets directly from the client (browser) without the server acting as an intermediary.
This also solves the 4.5 MB file size limit that the server uploads have.

* fix: correct HTTP status code in case of insufficient user rights
  • Loading branch information
drahoja9 authored Aug 26, 2024
1 parent fac8365 commit 13811f3
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 65 deletions.
54 changes: 40 additions & 14 deletions app/upload/UploadForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useRef, useState, type FormEvent } from "react";

import { type PutBlobResult } from "@vercel/blob";
import { upload } from "@vercel/blob/client";
import clsx from "clsx";

import { CopyToClipboardButton } from "~/components/CopyToClipboardButton";
Expand All @@ -19,24 +20,22 @@ export const UploadForm = () => {
if (!inputFileRef.current?.files) {
throw new Error("No file selected");
}

const file = inputFileRef.current.files[0];
if (!inputFileRef.current.files[0].name) {
throw new Error("Missing filename");
}

setUploading(true);
setError(false);
const response = await fetch(`/upload/upload?filename=${file.name}`, {
method: "POST",
body: file,
});

if (response.ok) {
const newBlob = (await response.json()) as PutBlobResult;
try {
const file = inputFileRef.current.files[0];
const newBlob = await uploadFile(file);
setBlob(newBlob);
setUploading(false);
} else {
} catch {
setError(true);
setUploading(false);
}

setUploading(false);
};

return (
Expand Down Expand Up @@ -67,9 +66,8 @@ export const UploadForm = () => {

const ErrorView = () => (
<p className="max-w-prose">
⚠️ Soubor se nepovedlo nahrát. Není moc veliký? Jsi přihlášený nebo
přihlášená? Máš oprávnění nahrávat soubory? Samé otázky. V případě potřeby
se{" "}
⚠️ Soubor se nepovedlo nahrát. Jsi přihlášený nebo přihlášená? Máš oprávnění
nahrávat soubory? Samé otázky. V případě potřeby se{" "}
<a
className="typo-link"
href="https://cesko-digital.slack.com/archives/CHG9NA23D"
Expand All @@ -95,3 +93,31 @@ const ResultView = ({ uploadedFileUrl }: { uploadedFileUrl: string }) => {
</p>
);
};

/**
* Function for generating SHA-1 hash of a file contents.
*
* Courtesy of MDN Web Docs:
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
*/
async function SHA1Hash(file: File): Promise<string> {
const data = await file.arrayBuffer();
const hashBuffer = await window.crypto.subtle.digest("SHA-1", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert bytes to hex string.
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}

async function SHA1Prefix(file: File): Promise<string> {
return (await SHA1Hash(file)).slice(0, 8);
}

async function uploadFile(file: File): Promise<PutBlobResult> {
const hash = await SHA1Prefix(file);
const fileExtension = file.name.split(".").pop();
const target = `${hash}.${fileExtension}`;
return await upload(target, file, {
access: "public",
handleUploadUrl: "/upload/upload",
});
}
3 changes: 1 addition & 2 deletions app/upload/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export default async function UploadPage() {
<h1 className="typo-title mb-10 mt-7">Nahrát data</h1>
<p className="mb-10 max-w-prose">
Tady můžeš snadno nahrát například obrázek nebo PDF, ke kterému chceš
mít veřejné URL. Aktuálně jde takhle nahrávat pouze soubory zhruba do
velikosti 4 MB, pokud potřebuješ větší, ozvi se.
mít veřejné URL.
</p>
<UploadForm />
</main>
Expand Down
82 changes: 33 additions & 49 deletions app/upload/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,41 @@
import crypto from "crypto";

import { NextResponse } from "next/server";

import { put } from "@vercel/blob";
import { getServerSession } from "next-auth";
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";

import { authOptions, type OurUser } from "~/src/auth";
import { withAuthenticatedUser } from "~/src/auth";
import { getUserProfile } from "~/src/data/user-profile";

export async function POST(request: Request): Promise<Response> {
// Only allow signed-in users to upload assets
const session = await getServerSession(authOptions);
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}

// And only users with the `assetUpload` feature flag may upload
const user = await getUserProfile((session.user as OurUser).id);
if (!user?.featureFlags.includes("assetUpload")) {
return new Response("Unauthorized", { status: 401 });
}

if (!request.body) {
return new Response("Missing payload", { status: 400 });
}

// We use the original filename to get the extension
const { searchParams } = new URL(request.url);
const originalFilename = searchParams.get("filename");
if (!originalFilename) {
return new Response("Missing filename", { status: 400 });
}

// But the file name itself is an 8-character prefix of the SHA1
// of file content
const data = await getArrayBufferView(request.body);
const hash = shasumPrefix(new Uint8Array(data));
const target = hash + "." + getFilenameExtension(originalFilename);

// Upload
const blob = await put(target, data, {
addRandomSuffix: false,
access: "public",
return withAuthenticatedUser(async ({ id }) => {
// Only users with the `assetUpload` feature flag may upload
const user = await getUserProfile(id);
if (!user?.featureFlags.includes("assetUpload")) {
return NextResponse.json(
{
error: `Feature flag 'assetUpload' not set for user '${user?.name}'.`,
},
{ status: 403 },
);
}

const body = (await request.json()) as HandleUploadBody;

try {
// The `handleUpload` function will generate a token for uploading the file from the client (browser).
const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async () => ({ addRandomSuffix: false }),
onUploadCompleted: async () => {},
});
return NextResponse.json(jsonResponse);
} catch (error) {
console.error("Error uploading blob", error);
return NextResponse.json(
{ error: (error as Error).message },
// The webhook will retry 5 times waiting for a 200 status code.
{ status: 400 },
);
}
});

return NextResponse.json(blob);
}

const getArrayBufferView = async (data: ReadableStream<Uint8Array>) =>
new Response(data).arrayBuffer();

const shasumPrefix = (data: crypto.BinaryLike) =>
crypto.createHash("sha1").update(data).digest("hex").slice(0, 8);

const getFilenameExtension = (name: string) =>
name.split(".").pop()?.toLowerCase();

0 comments on commit 13811f3

Please sign in to comment.