This commit is contained in:
@kirill.komarov
2026-05-10 17:15:56 +05:00
parent e67d8bdc0a
commit 517cd23a55
17 changed files with 151 additions and 37 deletions
@@ -1,5 +1,6 @@
import type { Category, Product } from '@/entities/product/model/types' import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
export type PublicProductsResponse = { export type PublicProductsResponse = {
items: Product[] items: Product[]
@@ -97,3 +98,26 @@ export async function createCategory(body: { name: string; slug?: string; sort?:
const { data } = await apiClient.post<Category>('admin/categories', body) const { data } = await apiClient.post<Category>('admin/categories', body)
return data return data
} }
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
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
}
@@ -9,10 +9,8 @@ export async function postProductReview(
export async function uploadReviewImage(file: File): Promise<{ url: string }> { export async function uploadReviewImage(file: File): Promise<{ url: string }> {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd, { const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
headers: { 'Content-Type': 'multipart/form-data' },
})
return data return data
} }
+12 -13
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
@@ -29,9 +29,9 @@ import {
fetchAdminProducts, fetchAdminProducts,
fetchCategories, fetchCategories,
updateProduct, updateProduct,
uploadAdminProductImages,
} from '@/entities/product/api/product-api' } from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types' import type { Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { getErrorMessage } from '@/shared/lib/get-error-message' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
@@ -228,23 +228,22 @@ export function AdminPage() {
else createMut.mutate() else createMut.mutate()
} }
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error const productImagesInputRef = useRef<HTMLInputElement>(null)
const uploadImagesMut = useMutation({ const uploadImagesMut = useMutation({
mutationFn: async (files: FileList) => { mutationFn: (picked: File[]) => uploadAdminProductImages(picked),
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
},
onSuccess: (urls) => { onSuccess: (urls) => {
const current = productForm.getValues('imageUrls') const current = productForm.getValues('imageUrls')
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true }) 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 removeImage = (url: string) => {
const current = productForm.getValues('imageUrls') const current = productForm.getValues('imageUrls')
productForm.setValue( productForm.setValue(
@@ -386,6 +385,7 @@ export function AdminPage() {
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}> <Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
Выбрать файлы Выбрать файлы
<input <input
ref={productImagesInputRef}
hidden hidden
type="file" type="file"
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp"
@@ -393,8 +393,7 @@ export function AdminPage() {
onChange={(e) => { onChange={(e) => {
const files = e.target.files const files = e.target.files
if (!files || files.length === 0) return if (!files || files.length === 0) return
uploadImagesMut.mutate(files) uploadImagesMut.mutate(Array.from(files))
e.currentTarget.value = ''
}} }}
/> />
</Button> </Button>
+9 -6
View File
@@ -1,19 +1,22 @@
import axios from 'axios' import axios, { AxiosHeaders } from 'axios'
import { apiBaseURL } from '@/shared/config' import { apiBaseURL } from '@/shared/config'
// Глобальный application/json ломает FormData: axios сериализует его в JSON. Тип для JSON задаёт transformRequest.
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: apiBaseURL, baseURL: apiBaseURL,
headers: { 'Content-Type': 'application/json' },
}) })
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
try { try {
config.headers = AxiosHeaders.from(config.headers ?? {})
const token = localStorage.getItem('craftshop_auth_token') const token = localStorage.getItem('craftshop_auth_token')
if (!token) return config if (token) {
config.headers = config.headers ?? {} config.headers.set('Authorization', `Bearer ${token}`)
config.headers.Authorization = `Bearer ${token}` }
// FormData: нельзя задавать Content-Type вручную (нужен boundary). Иначе сервер не видит файлы → { urls: [] }.
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
delete config.headers['Content-Type'] config.headers.delete('Content-Type')
config.headers.delete('content-type')
} }
return config return config
} catch { } catch {
+5
View File
@@ -3,6 +3,11 @@ PORT=3333
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
JWT_SECRET=замените-на-секрет-jwt 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 # Только приватный стенд: фиксированный код входа (без письма), см. server/.dev_env и npm run dev/start:dev_env
# IS_DEFAULT_CODE_ENABLED=true # IS_DEFAULT_CODE_ENABLED=true
# DEFAULT_CODE=123456 # DEFAULT_CODE=123456
+6 -2
View File
@@ -6,6 +6,7 @@ import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
import path from 'node:path' import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js' import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { registerAuth } from './plugins/auth.js' import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
@@ -17,7 +18,10 @@ const origin = (process.env.CORS_ORIGIN ?? '')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
const fastify = Fastify({ logger: true }) const fastify = Fastify({
logger: true,
bodyLimit: getMaxUploadBodyBytes(),
})
await fastify.register(cors, { await fastify.register(cors, {
origin: origin.length ? origin : true, origin: origin.length ? origin : true,
@@ -31,7 +35,7 @@ await fastify.register(jwt, {
await fastify.register(multipart, { await fastify.register(multipart, {
limits: { limits: {
files: 10, files: 10,
fileSize: 10 * 1024 * 1024, fileSize: getProductImageMaxFileBytes(),
}, },
}) })
+14 -3
View File
@@ -14,7 +14,7 @@ export function uploadError(message, statusCode = 400) {
return err return err
} }
export async function persistMultipartImages(request, { maxFiles = 10 } = {}) { export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes }) {
if (!request.isMultipart()) { if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data') throw uploadError('Ожидается multipart/form-data')
} }
@@ -23,9 +23,14 @@ export async function persistMultipartImages(request, { maxFiles = 10 } = {}) {
await fs.promises.mkdir(uploadsDir, { recursive: true }) await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = [] const urls = []
const parts = request.parts() const parts = request.parts({
limits: {
fileSize: maxFileBytes,
files: maxFiles,
},
})
for await (const part of parts) { for await (const part of parts) {
if (part.type !== 'file') continue if (!part.file) continue
if (urls.length >= maxFiles) { if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`) throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
} }
@@ -40,6 +45,12 @@ export async function persistMultipartImages(request, { maxFiles = 10 } = {}) {
urls.push(`/uploads/${fileName}`) urls.push(`/uploads/${fileName}`)
} }
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls return urls
} }
+38
View File
@@ -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} МБ).`
}
+15 -3
View File
@@ -1,4 +1,9 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import {
formatFileTooLargeMessage,
getProductImageMaxFileBytes,
isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js'
import { persistMultipartImages } from '../../lib/upload-images.js' import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerAdminProductRoutes( export async function registerAdminProductRoutes(
@@ -22,14 +27,21 @@ export async function registerAdminProductRoutes(
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
try { try {
const urls = await persistMultipartImages(request, { maxFiles: 10 }) const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
})
return { urls } return { urls }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
const statusCode = let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode) ? Number(error.statusCode)
: 400 : 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message }) return reply.code(statusCode).send({ error: message })
} }
}, },
+15 -3
View File
@@ -1,5 +1,10 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js' import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import {
formatFileTooLargeMessage,
getOtherUploadMaxFileBytes,
isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js'
import { persistMultipartImages } from '../../lib/upload-images.js' import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerPublicReviewRoutes(fastify) { export async function registerPublicReviewRoutes(fastify) {
@@ -8,15 +13,22 @@ export async function registerPublicReviewRoutes(fastify) {
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
try { 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 изображение' }) if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
return { url: urls[0] } return { url: urls[0] }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
const statusCode = let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode) ? Number(error.statusCode)
: 400 : 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message }) return reply.code(statusCode).send({ error: message })
} }
}, },
+10 -3
View File
@@ -1,6 +1,7 @@
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
import { escapeHtml } from '../lib/escape-html.js' import { escapeHtml } from '../lib/escape-html.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
import { saveImageBufferToUploads } from '../lib/upload-images.js' import { saveImageBufferToUploads } from '../lib/upload-images.js'
function mapUserForClient(user) { function mapUserForClient(user) {
@@ -724,9 +725,15 @@ export async function registerAuthRoutes(fastify) {
let receiptBuffer = null let receiptBuffer = null
let receiptFilename = '' let receiptFilename = ''
try { try {
const parts = request.parts() const otherLimit = getOtherUploadMaxFileBytes()
const parts = request.parts({
limits: {
fileSize: otherLimit,
files: 2,
},
})
for await (const part of parts) { for await (const part of parts) {
if (part.type === 'file') { if (part.file) {
if (part.fieldname === 'receipt') { if (part.fieldname === 'receipt') {
if (receiptBuffer !== null) { if (receiptBuffer !== null) {
return reply.code(400).send({ error: 'Допускается один файл receipt' }) return reply.code(400).send({ error: 'Допускается один файл receipt' })
@@ -734,7 +741,7 @@ export async function registerAuthRoutes(fastify) {
receiptBuffer = await part.toBuffer() receiptBuffer = await part.toBuffer()
receiptFilename = part.filename ?? 'receipt' receiptFilename = part.filename ?? 'receipt'
} }
} else if (part.type === 'field' && part.fieldname === 'detail') { } else if (part.fieldname === 'detail') {
detail = String(part.value ?? '').trim() detail = String(part.value ?? '').trim()
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

@@ -0,0 +1 @@
x