deploy
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} МБ).`
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user