Пошук уроків, статей та іншого контенту
Приймайте multipart/form-data, перевіряйте файли та безпечно обробляйте їх у File Upload API.
multipart/form-dataФайли не передають як звичайний JSON. Для цього браузер використовує формат multipart/form-data, у якому кожна частина запиту може містити:
текстове поле;
файл;
метадані файлу: ім’я, MIME-тип і розмір.
У Next.js App Router файл можна отримати через request.formData() у Route Handler.
POST /api/upload
Content-Type: multipart/form-dataНа відміну від JSON, під час надсилання multipart/form-data не потрібно вручну встановлювати заголовок Content-Type. Браузер сам додасть boundary, який розділяє частини запиту.
Створимо файл:
app/api/upload/route.tsAPI прийматиме одне поле file, перевірятиме:
чи поле справді містить файл;
чи не перевищує файл максимальний розмір;
чи дозволений його MIME-тип;
чи відповідають перші байти файлу заявленому типу;
чи безпечна назва файлу.
Для збереження використаємо випадкове ім’я, а не ім’я, передане клієнтом.
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { Buffer } from "node:buffer";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const allowedTypes = {
"image/jpeg": {
extension: "jpg",
matches(buffer: Buffer) {
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
},
},
"image/png": {
extension: "png",
matches(buffer: Buffer) {
const pngSignature = Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a,
]);
return buffer.subarray(0, 8).equals(pngSignature);
},
},
} as const;
export async function POST(request: Request) {
try {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json(
{ error: "Поле file має містити файл" },
{ status: 400 },
);
}
if (value.size === 0) {
return NextResponse.json(
{ error: "Файл порожній" },
{ status: 400 },
);
}
if (value.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "Максимальний розмір файлу — 5 МБ" },
{ status: 413 },
);
}
const fileType = allowedTypes[value.type as keyof typeof allowedTypes];
if (!fileType) {
return NextResponse.json(
{ error: "Підтримуються лише JPEG та PNG" },
{ status: 415 },
);
}
const fileBuffer = Buffer.from(await value.arrayBuffer());
if (!fileType.matches(fileBuffer)) {
return NextResponse.json(
{ error: "Вміст файлу не відповідає його MIME-типу" },
{ status: 400 },
);
}
const uploadDirectory = path.join(process.cwd(), "storage", "uploads");
await mkdir(uploadDirectory, { recursive: true });
const storedFileName = `${randomUUID()}.${fileType.extension}`;
const storedFilePath = path.join(uploadDirectory, storedFileName);
await writeFile(storedFilePath, fileBuffer, {
mode: 0o600,
});
return NextResponse.json(
{
message: "Файл успішно завантажено",
file: {
name: storedFileName,
size: value.size,
type: value.type,
},
},
{ status: 201 },
);
} catch (error) {
console.error("Помилка завантаження файлу:", error);
return NextResponse.json(
{ error: "Не вдалося обробити файл" },
{ status: 500 },
);
}
}У цьому прикладі використано Node.js runtime, оскільки код працює з файловою системою через node:fs/promises і process.cwd().
Поле форми повинно мати ім’я file, адже саме його шукає API через formData.get("file").
<form action="/api/upload" method="post" enctype="multipart/form-data">
<label>
Зображення
<input type="file" name="file" accept="image/jpeg,image/png" />
</label>
<button type="submit">Завантажити</button>
</form>Атрибут enctype="multipart/form-data" обов’язковий. Без нього браузер не передасть файл у потрібному форматі.
const input = document.querySelector("input[type=file]");
const file = input.files[0];
if (!file) {
throw new Error("Оберіть файл");
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error);
}
console.log(result);Не додавайте Content-Type вручну:
// Неправильно
headers: {
"Content-Type": "multipart/form-data",
}У такому разі можна втратити boundary, і сервер не зможе правильно розібрати тіло запиту. При використанні FormData браузер сам встановлює потрібний заголовок.
curlcurl -X POST \
-F "file=@./photo.png" \
http://localhost:3000/api/uploadОпція -F формує multipart-запит із файлом.
MIME-тип надходить від клієнта:
value.typeКлієнт може передати будь-яке значення, тому воно не є доказом реального формату файлу. Наприклад, шкідливий або перейменований файл може заявити MIME-тип image/png.
Тому перевірка повинна складатися щонайменше з двох частин:
перевірка заявленого MIME-типу;
перевірка сигнатури файлу, тобто його перших байтів.
У прикладі:
PNG перевіряється за стандартною 8-байтовою сигнатурою;
JPEG перевіряється за байтами FF D8 FF.
Перевірка сигнатури не замінює повний аналіз файлу, але не дозволяє просто перейменувати довільний файл у .png і пройти базову перевірку.
Не використовуйте ім’я від користувача безпосередньо в шляху:
// Небезпечно
const filePath = path.join(uploadDirectory, value.name);Ім’я може містити:
../ та спробу вийти з потрібної директорії;
незвичайні керівні символи;
конфлікт із вже наявним файлом;
розширення, яке не відповідає вмісту.
Надійніший підхід:
згенерувати нове ім’я на сервері;
використовувати randomUUID() або інший криптографічно стійкий ідентифікатор;
додати розширення з серверної таблиці дозволених типів;
зберігати оригінальне ім’я лише як метадані, якщо воно потрібне користувачу.
У прикладі клієнтське ім’я взагалі не використовується для створення шляху.
Обмежуйте розмір до читання всього файлу в пам’ять:
if (value.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "Файл надто великий" },
{ status: 413 },
);
}Це зменшує ризик надмірного споживання пам’яті. У прикладі після перевірки дозволеного розміру файл читається через arrayBuffer().
Обмеження мають бути узгоджені на кількох рівнях:
у клієнтському інтерфейсі;
у Route Handler;
у reverse proxy або платформі розгортання.
Перевірка на клієнті покращує взаємодію з користувачем, але не є захистом: клієнтський код можна обійти.
У прикладі файли записуються в:
storage/uploadsЦя директорія не є публічною, тому файл не стає доступним за URL автоматично. Це важливо для приватних документів.
Зберігання в локальній файловій системі підходить для локальної розробки або сервера з постійним диском. У багатьох хмарних середовищах локальна файлова система може бути тимчасовою: файл зникне після нового деплою або перезапуску середовища.
Для production зазвичай використовують зовнішнє сховище об’єктів. API при цьому все одно повинно виконувати ті самі перевірки до передавання файлу у сховище:
розмір;
дозволений тип;
сигнатура;
безпечне ім’я;
права доступу.
API має повертати зрозумілі HTTP-статуси:
400 Bad Request — відсутнє поле або некоректний вміст;
413 Payload Too Large — файл перевищує ліміт;
415 Unsupported Media Type — тип не підтримується;
500 Internal Server Error — внутрішня помилка сервера;
201 Created — файл успішно створено.
Не повертайте клієнту повний текст внутрішньої помилки. Він може розкрити шляхи на диску, налаштування сервера або інші службові дані. Деталі записуйте в серверний лог, а клієнту повертайте загальне повідомлення.
if (value.name.endsWith(".png")) {
// Файл нібито дозволений
}Розширення — це частина імені, яку користувач може змінити. Перевіряйте MIME-тип і сигнатуру вмісту.
const filePath = path.join("uploads", value.name);Це створює ризики обходу директорій, перезапису файлів і проблем із неочікуваними символами. Генеруйте серверне ім’я.
const buffer = Buffer.from(await value.arrayBuffer());
await writeFile(filePath, buffer);Без обмеження типів API може приймати виконувані файли, великі архіви або інший небажаний вміст.
Content-Type вручнуПід час відправлення FormData не задавайте Content-Type самостійно. Boundary повинен додати клієнт.
Атрибут accept і перевірки JavaScript можна обійти прямим HTTP-запитом. Усі правила обов’язково повторюйте на сервері.
Файл у public може стати доступним кожному, хто знає його URL. Для приватних завантажень використовуйте непублічне сховище та окрему перевірку доступу.
Для файлів API використовує multipart/form-data.
У Route Handler файл отримується через await request.formData().
Значення форми потрібно перевірити, перш ніж використовувати його як File.
Розмір файлу перевіряйте до читання його в пам’ять.
MIME-тип надходить від клієнта, тому додатково перевіряйте сигнатуру вмісту.
Не використовуйте оригінальне ім’я як частину шляху.
Генеруйте випадкове серверне ім’я та зберігайте файл у непублічній директорії.
Під час надсилання FormData не встановлюйте Content-Type вручну.
Ліміти й перевірки на клієнті не замінюють серверну валідацію.