base commit
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
PORT=3333
|
||||
ADMIN_API_TOKEN=замените-на-секрет
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
JWT_SECRET=замените-на-секрет-jwt
|
||||
|
||||
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+20
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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: 'Недостаточно прав' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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: 'Блок не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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) }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user