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
+5
View File
@@ -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
+6 -2
View File
@@ -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(),
},
})
+14 -3
View File
@@ -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
}
+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 {
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 })
}
},
+15 -3
View File
@@ -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 })
}
},
+10 -3
View File
@@ -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()
}
}
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