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
+1 -1
View File
@@ -1,6 +1,6 @@
DATABASE_URL="file:./dev.db"
PORT=3333
ADMIN_API_TOKEN=замените-на-секрет
ADMIN_EMAIL=admin@example.com
JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
-10
View File
@@ -13,7 +13,6 @@
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"nodemailer": "^8.0.7"
@@ -456,15 +455,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
-1
View File
@@ -19,7 +19,6 @@
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"nodemailer": "^8.0.7"
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "Review" ADD COLUMN "imageUrl" TEXT;
-- CreateTable
CREATE TABLE "InfoPageBlock" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"sort" INTEGER NOT NULL DEFAULT 0,
"published" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "InfoPageBlock_key_key" ON "InfoPageBlock"("key");
-- CreateIndex
CREATE INDEX "InfoPageBlock_published_sort_idx" ON "InfoPageBlock"("published", "sort");
+14
View File
@@ -161,6 +161,7 @@ model Review {
id String @id @default(cuid())
rating Int
text String?
imageUrl String?
/// 'pending' | 'approved' | 'rejected'
status String @default("pending")
createdAt DateTime @default(now())
@@ -229,3 +230,16 @@ model AuthCode {
@@index([email, purpose])
@@index([expiresAt])
}
model InfoPageBlock {
id String @id @default(cuid())
key String @unique
title String
body String
sort Int @default(0)
published Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, sort])
}
+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) }
},
)