diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts index 43f62f2..b082713 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -1,5 +1,6 @@ import type { Category, Product } from '@/entities/product/model/types' import { apiClient } from '@/shared/api/client' +import { apiBaseURL } from '@/shared/config' export type PublicProductsResponse = { items: Product[] @@ -97,3 +98,26 @@ export async function createCategory(body: { name: string; slug?: string; sort?: const { data } = await apiClient.post('admin/categories', body) return data } + +/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */ +export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise { + const fd = new FormData() + for (const f of Array.from(files)) { + fd.append('files', f, f.name) + } + const token = localStorage.getItem('craftshop_auth_token') + const base = apiBaseURL.replace(/\/$/, '') + const res = await fetch(`${base}/admin/uploads`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }) + const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string } + if (!res.ok) { + throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`) + } + if (!Array.isArray(payload.urls)) { + throw new Error('Некорректный ответ сервера') + } + return payload.urls +} diff --git a/client/src/entities/product/api/reviews-api.ts b/client/src/entities/product/api/reviews-api.ts index 2c9dcbd..db57bc5 100644 --- a/client/src/entities/product/api/reviews-api.ts +++ b/client/src/entities/product/api/reviews-api.ts @@ -9,10 +9,8 @@ export async function postProductReview( export async function uploadReviewImage(file: File): Promise<{ url: string }> { const fd = new FormData() - fd.append('file', file) - const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) + fd.append('file', file, file.name) + const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd) return data } diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index d83d875..835fd27 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -29,9 +29,9 @@ import { fetchAdminProducts, fetchCategories, updateProduct, + uploadAdminProductImages, } from '@/entities/product/api/product-api' import type { Product } from '@/entities/product/model/types' -import { apiClient } from '@/shared/api/client' import { formatPriceRub } from '@/shared/lib/format-price' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' @@ -228,23 +228,22 @@ export function AdminPage() { else createMut.mutate() } - const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error + const productImagesInputRef = useRef(null) const uploadImagesMut = useMutation({ - mutationFn: async (files: FileList) => { - const fd = new FormData() - Array.from(files).forEach((f) => fd.append('files', f)) - const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) - return data.urls - }, + mutationFn: (picked: File[]) => uploadAdminProductImages(picked), onSuccess: (urls) => { const current = productForm.getValues('imageUrls') productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true }) + if (productImagesInputRef.current) { + productImagesInputRef.current.value = '' + } }, }) + const mutationError = + createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error ?? uploadImagesMut.error + const removeImage = (url: string) => { const current = productForm.getValues('imageUrls') productForm.setValue( @@ -386,6 +385,7 @@ export function AdminPage() { diff --git a/client/src/shared/api/client.ts b/client/src/shared/api/client.ts index d43599d..9916666 100644 --- a/client/src/shared/api/client.ts +++ b/client/src/shared/api/client.ts @@ -1,19 +1,22 @@ -import axios from 'axios' +import axios, { AxiosHeaders } from 'axios' import { apiBaseURL } from '@/shared/config' +// Глобальный application/json ломает FormData: axios сериализует его в JSON. Тип для JSON задаёт transformRequest. export const apiClient = axios.create({ baseURL: apiBaseURL, - headers: { 'Content-Type': 'application/json' }, }) apiClient.interceptors.request.use((config) => { try { + config.headers = AxiosHeaders.from(config.headers ?? {}) const token = localStorage.getItem('craftshop_auth_token') - if (!token) return config - config.headers = config.headers ?? {} - config.headers.Authorization = `Bearer ${token}` + if (token) { + config.headers.set('Authorization', `Bearer ${token}`) + } + // FormData: нельзя задавать Content-Type вручную (нужен boundary). Иначе сервер не видит файлы → { urls: [] }. if (config.data instanceof FormData) { - delete config.headers['Content-Type'] + config.headers.delete('Content-Type') + config.headers.delete('content-type') } return config } catch { diff --git a/server/.env.example b/server/.env.example index 0ddd0e9..4175d8d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -3,6 +3,11 @@ PORT=3333 ADMIN_EMAIL=admin@example.com JWT_SECRET=замените-на-секрет-jwt +# Загрузки (байты). Фото товара в админке: по умолчанию 20 МБ; отзывы/чек и т.п.: 2 МБ. +# PRODUCT_IMAGE_MAX_FILE_BYTES=20971520 +# OTHER_UPLOAD_MAX_FILE_BYTES=2097152 +# MAX_UPLOAD_BODY_BYTES=… — весь POST multipart при необходимости + # Только приватный стенд: фиксированный код входа (без письма), см. server/.dev_env и npm run dev/start:dev_env # IS_DEFAULT_CODE_ENABLED=true # DEFAULT_CODE=123456 diff --git a/server/src/index.js b/server/src/index.js index 9b921aa..61ecf69 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -6,6 +6,7 @@ import multipart from '@fastify/multipart' import fastifyStatic from '@fastify/static' import path from 'node:path' import { ensureAdminUser } from './lib/bootstrap-admin.js' +import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { registerAuth } from './plugins/auth.js' import { registerApiRoutes } from './routes/api.js' import { registerAuthRoutes } from './routes/auth.js' @@ -17,7 +18,10 @@ const origin = (process.env.CORS_ORIGIN ?? '') .map((s) => s.trim()) .filter(Boolean) -const fastify = Fastify({ logger: true }) +const fastify = Fastify({ + logger: true, + bodyLimit: getMaxUploadBodyBytes(), +}) await fastify.register(cors, { origin: origin.length ? origin : true, @@ -31,7 +35,7 @@ await fastify.register(jwt, { await fastify.register(multipart, { limits: { files: 10, - fileSize: 10 * 1024 * 1024, + fileSize: getProductImageMaxFileBytes(), }, }) diff --git a/server/src/lib/upload-images.js b/server/src/lib/upload-images.js index bff294e..915db68 100644 --- a/server/src/lib/upload-images.js +++ b/server/src/lib/upload-images.js @@ -14,7 +14,7 @@ export function uploadError(message, statusCode = 400) { return err } -export async function persistMultipartImages(request, { maxFiles = 10 } = {}) { +export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes }) { if (!request.isMultipart()) { throw uploadError('Ожидается multipart/form-data') } @@ -23,9 +23,14 @@ export async function persistMultipartImages(request, { maxFiles = 10 } = {}) { await fs.promises.mkdir(uploadsDir, { recursive: true }) const urls = [] - const parts = request.parts() + const parts = request.parts({ + limits: { + fileSize: maxFileBytes, + files: maxFiles, + }, + }) for await (const part of parts) { - if (part.type !== 'file') continue + if (!part.file) continue if (urls.length >= maxFiles) { throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`) } @@ -40,6 +45,12 @@ export async function persistMultipartImages(request, { maxFiles = 10 } = {}) { urls.push(`/uploads/${fileName}`) } + if (urls.length === 0) { + throw uploadError( + 'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).', + ) + } + return urls } diff --git a/server/src/lib/upload-limits.js b/server/src/lib/upload-limits.js new file mode 100644 index 0000000..5175a73 --- /dev/null +++ b/server/src/lib/upload-limits.js @@ -0,0 +1,38 @@ +const MB = 1024 * 1024 + +/** Фото товаров в админке (на файл). По умолчанию 20 МБ. */ +export const PRODUCT_IMAGE_MAX_FILE_BYTES = 20 * MB + +/** Отзывы, чек оплаты и прочие загрузки (на файл). По умолчанию 2 МБ. */ +export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * MB + +export function getProductImageMaxFileBytes() { + const n = Number(process.env.PRODUCT_IMAGE_MAX_FILE_BYTES) + return Number.isFinite(n) && n > 0 ? Math.floor(n) : PRODUCT_IMAGE_MAX_FILE_BYTES +} + +export function getOtherUploadMaxFileBytes() { + const n = Number(process.env.OTHER_UPLOAD_MAX_FILE_BYTES) + return Number.isFinite(n) && n > 0 ? Math.floor(n) : OTHER_UPLOAD_MAX_FILE_BYTES +} + +/** Лимит тела HTTP: до 10 фото товара за запрос + запас. */ +export function getMaxUploadBodyBytes() { + const n = Number(process.env.MAX_UPLOAD_BODY_BYTES) + if (Number.isFinite(n) && n > 0) return Math.floor(n) + return getProductImageMaxFileBytes() * 10 + MB +} + +/** @param {unknown} error */ +export function isMultipartFileTooLargeError(error) { + if (!error || typeof error !== 'object') return false + if (error.code === 'FST_REQ_FILE_TOO_LARGE') return true + const msg = String(Reflect.get(error, 'message') ?? '') + return /request file too large|file too large/i.test(msg) +} + +/** @param {number} maxFileBytes */ +export function formatFileTooLargeMessage(maxFileBytes) { + const mb = Math.max(1, Math.round(maxFileBytes / MB)) + return `Файл слишком большой (максимум ${mb} МБ).` +} diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 8dc1c5b..dc97395 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -1,4 +1,9 @@ import { prisma } from '../../lib/prisma.js' +import { + formatFileTooLargeMessage, + getProductImageMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' import { persistMultipartImages } from '../../lib/upload-images.js' export async function registerAdminProductRoutes( @@ -22,14 +27,21 @@ export async function registerAdminProductRoutes( { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { try { - const urls = await persistMultipartImages(request, { maxFiles: 10 }) + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + }) return { urls } } catch (error) { - const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' - const statusCode = + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) ? Number(error.statusCode) : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 + } return reply.code(statusCode).send({ error: message }) } }, diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index 959bbb1..983fe21 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -1,5 +1,10 @@ import { publicReviewAuthorDisplay } from '../../lib/review-display.js' import { prisma } from '../../lib/prisma.js' +import { + formatFileTooLargeMessage, + getOtherUploadMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' import { persistMultipartImages } from '../../lib/upload-images.js' export async function registerPublicReviewRoutes(fastify) { @@ -8,15 +13,22 @@ export async function registerPublicReviewRoutes(fastify) { { preHandler: [fastify.authenticate] }, async (request, reply) => { try { - const urls = await persistMultipartImages(request, { maxFiles: 1 }) + const urls = await persistMultipartImages(request, { + maxFiles: 1, + maxFileBytes: getOtherUploadMaxFileBytes(), + }) if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) return { url: urls[0] } } catch (error) { - const message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' - const statusCode = + let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' + let statusCode = error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) ? Number(error.statusCode) : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes()) + statusCode = 413 + } return reply.code(statusCode).send({ error: message }) } }, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index e132a74..7919300 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,6 +1,7 @@ import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' import { escapeHtml } from '../lib/escape-html.js' import { prisma } from '../lib/prisma.js' +import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js' import { saveImageBufferToUploads } from '../lib/upload-images.js' function mapUserForClient(user) { @@ -724,9 +725,15 @@ export async function registerAuthRoutes(fastify) { let receiptBuffer = null let receiptFilename = '' try { - const parts = request.parts() + const otherLimit = getOtherUploadMaxFileBytes() + const parts = request.parts({ + limits: { + fileSize: otherLimit, + files: 2, + }, + }) for await (const part of parts) { - if (part.type === 'file') { + if (part.file) { if (part.fieldname === 'receipt') { if (receiptBuffer !== null) { return reply.code(400).send({ error: 'Допускается один файл receipt' }) @@ -734,7 +741,7 @@ export async function registerAuthRoutes(fastify) { receiptBuffer = await part.toBuffer() receiptFilename = part.filename ?? 'receipt' } - } else if (part.type === 'field' && part.fieldname === 'detail') { + } else if (part.fieldname === 'detail') { detail = String(part.value ?? '').trim() } } diff --git a/server/uploads/00c38f00-32b4-4774-b9c8-41b5e8084736.png b/server/uploads/00c38f00-32b4-4774-b9c8-41b5e8084736.png new file mode 100644 index 0000000..de905a9 Binary files /dev/null and b/server/uploads/00c38f00-32b4-4774-b9c8-41b5e8084736.png differ diff --git a/server/uploads/359ae923-5ad9-4a94-8edf-43942c28648d.png b/server/uploads/359ae923-5ad9-4a94-8edf-43942c28648d.png new file mode 100644 index 0000000..61be35f Binary files /dev/null and b/server/uploads/359ae923-5ad9-4a94-8edf-43942c28648d.png differ diff --git a/server/uploads/3bfc68cd-64d3-47c4-9b15-539a84c872cb.png b/server/uploads/3bfc68cd-64d3-47c4-9b15-539a84c872cb.png new file mode 100644 index 0000000..de905a9 Binary files /dev/null and b/server/uploads/3bfc68cd-64d3-47c4-9b15-539a84c872cb.png differ diff --git a/server/uploads/76f53cfd-709c-4f45-969b-449cc6e99e6b.png b/server/uploads/76f53cfd-709c-4f45-969b-449cc6e99e6b.png new file mode 100644 index 0000000..61be35f Binary files /dev/null and b/server/uploads/76f53cfd-709c-4f45-969b-449cc6e99e6b.png differ diff --git a/server/uploads/d7b0d739-e7d1-4265-a004-2cd0239fb44c.png b/server/uploads/d7b0d739-e7d1-4265-a004-2cd0239fb44c.png new file mode 100644 index 0000000..c3a9bdc Binary files /dev/null and b/server/uploads/d7b0d739-e7d1-4265-a004-2cd0239fb44c.png differ diff --git a/server/uploads/ee16c4a7-c9c3-47ee-b3c6-7ba55421a1f7.jpg b/server/uploads/ee16c4a7-c9c3-47ee-b3c6-7ba55421a1f7.jpg new file mode 100644 index 0000000..c1b0730 --- /dev/null +++ b/server/uploads/ee16c4a7-c9c3-47ee-b3c6-7ba55421a1f7.jpg @@ -0,0 +1 @@ +x \ No newline at end of file