base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
+2
View File
@@ -5,6 +5,7 @@ import jwt from '@fastify/jwt'
import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js'
@@ -52,6 +53,7 @@ registerAuth(fastify)
await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
fastify.get('/health', async () => ({ ok: true }))
-8
View File
@@ -1,5 +1,4 @@
import crypto from 'node:crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js'
@@ -54,11 +53,4 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
return true
}
export async function hashPassword(password) {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash)
}
+16
View File
@@ -0,0 +1,16 @@
import { normalizeEmail } from './auth.js'
import { prisma } from './prisma.js'
export async function ensureAdminUser() {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!adminEmail) return
if (!adminEmail.includes('@')) {
throw new Error('ADMIN_EMAIL должен быть валидным email')
}
await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: { email: adminEmail },
})
}
+44
View File
@@ -0,0 +1,44 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
export function safeImageExt(filename) {
const ext = path.extname(String(filename || '')).toLowerCase()
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
return allowed.has(ext) ? ext : null
}
export function uploadError(message, statusCode = 400) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
export async function persistMultipartImages(request, { maxFiles = 10 } = {}) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const fileName = `${crypto.randomUUID()}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return urls
}
+17 -10
View File
@@ -1,16 +1,23 @@
/**
* Простая защита админ-роутов: заголовок Authorization: Bearer <ADMIN_API_TOKEN>
*/
export function registerAuth(fastify) {
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
}
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
const token = process.env.ADMIN_API_TOKEN
if (!token) {
return reply.code(503).send({ error: 'ADMIN_API_TOKEN не задан в .env' })
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!adminEmail || !adminEmail.includes('@')) {
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
}
const auth = request.headers.authorization
const match = typeof auth === 'string' ? auth.match(/^Bearer\s+(.+)$/i) : null
if (!match?.[1] || match[1] !== token) {
return reply.code(401).send({ error: 'Неверный или отсутствующий токен' })
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: 'Не авторизован' })
}
const userEmail = normalizeEmail(request.user?.email)
if (userEmail !== adminEmail) {
return reply.code(403).send({ error: 'Недостаточно прав' })
}
})
}
+2 -2
View File
@@ -1,7 +1,6 @@
import {
mapProductForApi,
parseMaterialsInput,
safeExtFromFilename,
slugify,
} from './api/_product-helpers.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
@@ -9,16 +8,17 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerInfoPageRoutes } from './api/info-page.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js'
export async function registerApiRoutes(fastify) {
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
await registerPublicReviewRoutes(fastify)
await registerInfoPageRoutes(fastify)
await registerAdminProductRoutes(fastify, {
slugify,
safeExtFromFilename,
parseMaterialsInput,
mapProductForApi,
})
+12 -29
View File
@@ -1,11 +1,9 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerAdminProductRoutes(
fastify,
{ slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {},
{ slugify, parseMaterialsInput, mapProductForApi } = {},
) {
fastify.get(
'/api/admin/products',
@@ -23,32 +21,17 @@ export async function registerAdminProductRoutes(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
if (!request.isMultipart()) {
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
return
try {
const urls = await persistMultipartImages(request, { maxFiles: 10 })
return { urls }
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
const statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
return reply.code(statusCode).send({ error: message })
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
const ext = safeExtFromFilename(part.filename)
if (!ext) {
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
return
}
const id = crypto.randomUUID()
const fileName = `${id}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return { urls }
},
)
+1 -24
View File
@@ -1,5 +1,5 @@
import { prisma } from '../../lib/prisma.js'
import { hashPassword, normalizeEmail } from '../../lib/auth.js'
import { normalizeEmail } from '../../lib/auth.js'
export async function registerAdminUserRoutes(fastify) {
fastify.get(
@@ -36,7 +36,6 @@ export async function registerAdminUserRoutes(fastify) {
id: true,
email: true,
name: true,
passwordHash: true,
createdAt: true,
updatedAt: true,
},
@@ -48,7 +47,6 @@ export async function registerAdminUserRoutes(fastify) {
id: u.id,
email: u.email,
name: u.name,
hasPassword: Boolean(u.passwordHash),
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
@@ -76,24 +74,16 @@ export async function registerAdminUserRoutes(fastify) {
return
}
const password = body.password ? String(body.password) : ''
if (password && password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const passwordHash = password ? await hashPassword(password) : null
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
passwordHash: passwordHash ?? undefined,
},
})
@@ -101,7 +91,6 @@ export async function registerAdminUserRoutes(fastify) {
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
@@ -149,23 +138,11 @@ export async function registerAdminUserRoutes(fastify) {
data.name = name && name.length ? name : null
}
if (body.password !== undefined) {
const password = body.password ? String(body.password) : ''
if (password) {
if (password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
data.passwordHash = await hashPassword(password)
}
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
+118
View File
@@ -0,0 +1,118 @@
import { prisma } from '../../lib/prisma.js'
function validateBlockPayload(body, reply) {
const key = String(body?.key || '').trim()
const title = String(body?.title || '').trim()
const content = String(body?.body || '').trim()
const sort = Number(body?.sort ?? 0)
const published = body?.published === undefined ? true : Boolean(body.published)
if (!key) return reply.code(400).send({ error: 'key обязателен' })
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
}
if (!title) return reply.code(400).send({ error: 'title обязателен' })
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
if (!content) return reply.code(400).send({ error: 'body обязателен' })
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
return { key, title, body: content, sort: Math.trunc(sort), published }
}
export async function registerInfoPageRoutes(fastify) {
fastify.get('/api/info-page/blocks', async () => {
const items = await prisma.infoPageBlock.findMany({
where: { published: true },
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
})
return { items }
})
fastify.get(
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
return { items }
},
)
fastify.post(
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const validated = validateBlockPayload(request.body, reply)
if (!validated) return
try {
const item = await prisma.infoPageBlock.create({ data: validated })
return reply.code(201).send({ item })
} catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
}
},
)
fastify.patch(
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
const body = request.body ?? {}
const data = {}
if (body.key !== undefined) {
const key = String(body.key || '').trim()
if (!key) return reply.code(400).send({ error: 'key обязателен' })
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
}
data.key = key
}
if (body.title !== undefined) {
const title = String(body.title || '').trim()
if (!title) return reply.code(400).send({ error: 'title обязателен' })
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
data.title = title
}
if (body.body !== undefined) {
const content = String(body.body || '').trim()
if (!content) return reply.code(400).send({ error: 'body обязателен' })
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
data.body = content
}
if (body.sort !== undefined) {
const sort = Number(body.sort)
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
data.sort = Math.trunc(sort)
}
if (body.published !== undefined) {
data.published = Boolean(body.published)
}
try {
const item = await prisma.infoPageBlock.update({ where: { id }, data })
return { item }
} catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
}
},
)
fastify.delete(
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.infoPageBlock.delete({ where: { id } })
return reply.code(204).send()
} catch {
return reply.code(404).send({ error: 'Блок не найден' })
}
},
)
}
+29
View File
@@ -1,7 +1,27 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerPublicReviewRoutes(fastify) {
fastify.post(
'/api/reviews/upload-image',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, { maxFiles: 1 })
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 =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
return reply.code(statusCode).send({ error: message })
}
},
)
fastify.get('/api/reviews/latest', async (request, reply) => {
const limitRaw = request.query?.limit
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
@@ -22,6 +42,7 @@ export async function registerPublicReviewRoutes(fastify) {
id: r.id,
rating: r.rating,
text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
productId: r.productId,
@@ -60,6 +81,7 @@ export async function registerPublicReviewRoutes(fastify) {
id: r.id,
rating: r.rating,
text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
}))
@@ -84,6 +106,12 @@ export async function registerPublicReviewRoutes(fastify) {
const textRaw = request.body?.text
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
const imageUrlRaw = request.body?.imageUrl
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim()
if (imageUrl !== null && imageUrl.length > 300) return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
}
try {
const created = await prisma.review.create({
@@ -92,6 +120,7 @@ export async function registerPublicReviewRoutes(fastify) {
userId,
rating: Math.floor(rating),
text: text && text.length ? text : null,
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
status: 'pending',
},
})
+17 -61
View File
@@ -1,5 +1,17 @@
import { prisma } from '../lib/prisma.js'
import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js'
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
const userEmail = normalizeEmail(user.email)
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
}
}
export async function registerAuthRoutes(fastify) {
fastify.post('/api/auth/request-code', async (request, reply) => {
@@ -27,38 +39,7 @@ export async function registerAuthRoutes(fastify) {
})
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
})
fastify.post('/api/auth/register', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' })
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' })
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({ data: { email, passwordHash } })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } })
})
fastify.post('/api/auth/login', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!password) return reply.code(400).send({ error: 'Укажите пароль' })
const user = await prisma.user.findUnique({ where: { email } })
if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' })
const ok = await verifyPassword(password, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Неверные данные' })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
return { token, user: mapUserForClient(user) }
})
fastify.get(
@@ -68,7 +49,7 @@ export async function registerAuthRoutes(fastify) {
const userId = request.user.sub
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return { user: null }
return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
return { user: mapUserForClient(user) }
},
)
@@ -108,32 +89,7 @@ export async function registerAuthRoutes(fastify) {
where: { id: userId },
data: { email: newEmail },
})
return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
},
)
fastify.patch(
'/api/me/password',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : ''
const newPassword = String(request.body?.newPassword || '')
if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' })
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
if (user.passwordHash) {
if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' })
const ok = await verifyPassword(currentPassword, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' })
}
const passwordHash = await hashPassword(newPassword)
const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } }
return { user: mapUserForClient(user) }
},
)
@@ -160,7 +116,7 @@ export async function registerAuthRoutes(fastify) {
where: { id: userId },
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null },
})
return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } }
return { user: mapUserForClient(updated) }
},
)