This commit is contained in:
Kirill
2026-05-24 15:10:24 +05:00
parent 8d4ff3ef62
commit 88fedd675a
18 changed files with 347 additions and 32 deletions
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://любимыйкреатив.рф/sitemap.xml
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://любимыйкреатив.рф/</loc>
<priority>1.0</priority>
<changefreq>daily</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/info</loc>
<priority>0.8</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/about</loc>
<priority>0.7</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/privacy</loc>
<priority>0.5</priority>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/terms</loc>
<priority>0.5</priority>
<changefreq>yearly</changefreq>
</url>
</urlset>
@@ -7,7 +7,8 @@ const methods = [
{ {
icon: <CreditCard size={18} />, icon: <CreditCard size={18} />,
primary: 'Онлайн-оплата через ЮKassa', primary: 'Онлайн-оплата через ЮKassa',
secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', secondary:
'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.',
}, },
{ {
icon: <Banknote size={18} />, icon: <Banknote size={18} />,
@@ -39,7 +40,8 @@ export function PaymentSection() {
maxWidth: '56ch', maxWidth: '56ch',
}} }}
> >
Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате. Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и
готов к оплате.
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
@@ -36,7 +36,9 @@ export function ReturnsSection() {
lineHeight: 1.65, lineHeight: 1.65,
}} }}
> >
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней
после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества
возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
</Typography> </Typography>
</Box> </Box>
@@ -58,7 +60,9 @@ export function ReturnsSection() {
lineHeight: 1.65, lineHeight: 1.65,
}} }}
> >
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству напишите нам, и мы решим проблему в кратчайшие сроки. Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя,
устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству напишите нам, и мы
решим проблему в кратчайшие сроки.
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
+10 -2
View File
@@ -1,5 +1,12 @@
export type StatusColor = 'warning' | 'success' | 'info' | 'error' export type StatusColor = 'warning' | 'success' | 'info' | 'error'
export type StatusIconName = 'banknote' | 'check-circle' | 'package-search' | 'package' | 'package-check' | 'store' | 'x-circle' export type StatusIconName =
| 'banknote'
| 'check-circle'
| 'package-search'
| 'package'
| 'package-check'
| 'store'
| 'x-circle'
export interface OrderStatusData { export interface OrderStatusData {
code: string code: string
@@ -37,7 +44,8 @@ export const ORDER_STATUS_DATA: ReadonlyArray<OrderStatusData> = [
label: 'Отправлен', label: 'Отправлен',
iconName: 'package', iconName: 'package',
color: 'info', color: 'info',
description: 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.', description:
'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.',
}, },
{ {
code: 'READY_FOR_PICKUP', code: 'READY_FOR_PICKUP',
+70 -7
View File
@@ -6,20 +6,47 @@ import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-stat
const iconMap: Record<StatusIconName, ReactNode> = { const iconMap: Record<StatusIconName, ReactNode> = {
banknote: ( banknote: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="20" height="12" rx="2" /> <rect x="2" y="6" width="20" height="12" rx="2" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
<path d="M6 12h.01M18 12h.01" /> <path d="M6 12h.01M18 12h.01" />
</svg> </svg>
), ),
'check-circle': ( 'check-circle': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" /> <polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
), ),
'package-search': ( 'package-search': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /> <path d="M16 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
<path d="M19 19l-3-3" /> <path d="M19 19l-3-3" />
<path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" /> <path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" />
@@ -27,14 +54,32 @@ const iconMap: Record<StatusIconName, ReactNode> = {
</svg> </svg>
), ),
package: ( package: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /> <path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /> <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" /> <line x1="12" y1="22.08" x2="12" y2="12" />
</svg> </svg>
), ),
'package-check': ( 'package-check': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /> <path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /> <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" /> <line x1="12" y1="22.08" x2="12" y2="12" />
@@ -42,13 +87,31 @@ const iconMap: Record<StatusIconName, ReactNode> = {
</svg> </svg>
), ),
store: ( store: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" /> <polyline points="9 22 9 12 15 12 15 22" />
</svg> </svg>
), ),
'x-circle': ( 'x-circle': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" /> <path d="M15 9l-6 6M9 9l6 6" />
</svg> </svg>
+25
View File
@@ -169,3 +169,28 @@ curl http://127.0.0.1:3333/health
```bash ```bash
curl https://craftshop.твой-домен/api/health curl https://craftshop.твой-домен/api/health
``` ```
## 9. Бэкапы БД (systemd timer)
Установить таймер для автоматического бэкапа каждые 6 часов:
```bash
# Установить sqlite3 для безопасного копирования
apt-get install -y sqlite3
# Скопировать unit-файлы
cp /opt/craftshop/scripts/craftshop-backup.service /etc/systemd/system/
cp /opt/craftshop/scripts/craftshop-backup.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now craftshop-backup.timer
# Проверить статус
systemctl list-timers craftshop-backup.timer
# Ручной запуск для проверки
systemctl start craftshop-backup.service
ls /opt/craftshop/server/backups/
```
Бэкапы хранятся 30 дней (настраивается в `scripts/backup-db.sh`).
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Backup SQLite database — копирует .db файл с timestamp в директорию бэкапов.
# Вызывается из systemd timer или cron.
#
# Использование: ./scripts/backup-db.sh [path-to-db] [backup-dir] [retention-days]
# По умолчанию: db=server/prisma/prod.db, backup=server/backups, retention=30
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_PATH="${1:-$ROOT/server/prisma/prod.db}"
BACKUP_DIR="${2:-$ROOT/server/backups}"
RETENTION_DAYS="${3:-30}"
if [[ ! -f "$DB_PATH" ]]; then
echo "[$(date -Iseconds)] DB not found: $DB_PATH" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/craftshop_${TIMESTAMP}.db"
# SQLite-safe copy: use .backup to avoid copying a file mid-write
if command -v sqlite3 &>/dev/null; then
sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'"
else
# Fallback: plain copy (risk of inconsistent state if DB is being written)
cp "$DB_PATH" "$BACKUP_FILE"
fi
# Compress
gzip -f "$BACKUP_FILE"
# Remove old backups
find "$BACKUP_DIR" -name 'craftshop_*.db.gz' -mtime +"$RETENTION_DAYS" -delete
echo "[$(date -Iseconds)] Backup created: ${BACKUP_FILE}.gz"
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=Craftshop SQLite Database Backup
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/craftshop/scripts/backup-db.sh /opt/craftshop/server/prisma/prod.db /opt/craftshop/server/backups 30
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Craftshop Database Backup Timer
[Timer]
OnCalendar=*-*-* 00/6:00:00
Persistent=true
[Install]
WantedBy=timers.target
Binary file not shown.
+40 -1
View File
@@ -19,13 +19,14 @@ import { prisma } from './lib/prisma.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { registerAuth } from './plugins/auth.js' import { registerAuth } from './plugins/auth.js'
import { registerIpGate } from './plugins/ip-gate.js' import { registerIpGate } from './plugins/ip-gate.js'
import { registerSecurityHeaders } from './plugins/security-headers.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerSseRoutes } from './routes/sse.js'
import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUploadsResized } from './routes/uploads-resized.js'
import { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerUserNotificationRoutes } from './routes/user/notifications.js'
import { registerUserAddressRoutes } from './routes/user-addresses.js' import { registerUserAddressRoutes } from './routes/user-addresses.js'
import { registerUserCartRoutes } from './routes/user-cart.js' import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerSseRoutes } from './routes/sse.js'
import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerUserPaymentRoutes } from './routes/user-payments.js'
@@ -48,6 +49,44 @@ await fastify.register(cors, {
credentials: true, credentials: true,
}) })
await registerSecurityHeaders(fastify)
fastify.get('/health', async () => {
try {
await prisma.$queryRaw`SELECT 1`
return { status: 'ok', database: 'connected', uptime: process.uptime() }
} catch {
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
}
})
fastify.setErrorHandler(function errorHandler(error, request, reply) {
const isProd = process.env.NODE_ENV === 'production'
if (error.validation) {
return reply.code(400).send({
error: 'Ошибка валидации',
details: isProd ? undefined : error.validation,
})
}
if (error.code === 'FST_ERR_VALIDATION') {
return reply.code(400).send({ error: 'Неверный формат запроса' })
}
if (error.statusCode) {
return reply.code(error.statusCode).send({
error: error.message || 'Произошла ошибка',
})
}
request.log.error(error)
return reply.code(500).send({
error: isProd ? 'Внутренняя ошибка сервера' : error.message,
})
})
await fastify.register(jwt, { await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
}) })
+1 -1
View File
@@ -1,5 +1,5 @@
import { createAvatar } from '@dicebear/core'
import { avataaars } from '@dicebear/collection' import { avataaars } from '@dicebear/collection'
import { createAvatar } from '@dicebear/core'
const DEFAULT_STYLE = avataaars const DEFAULT_STYLE = avataaars
+34 -9
View File
@@ -1,26 +1,51 @@
const windows = new Map() const windows = new Map()
const MAX_ATTEMPTS = 5 const DEFAULT_MAX_ATTEMPTS = 5
const WINDOW_MS = 60_000 const DEFAULT_WINDOW_MS = 60_000
// Per-endpoint rate limits
const LIMITS = {
login: { maxAttempts: 5, windowMs: 60_000 },
codeRequest: { maxAttempts: 3, windowMs: 60_000 },
codeVerify: { maxAttempts: 5, windowMs: 60_000 },
}
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
for (const [ip, entry] of windows) { for (const [ip, entry] of windows) {
if (now - entry.start > WINDOW_MS) windows.delete(ip) if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip)
} }
}, 5 * 60_000).unref() }, 5 * 60_000).unref()
export function checkLoginRateLimit(ip) { function getKey(ip, scope) {
return `${scope}:${ip}`
}
function checkRateLimit(ip, scope) {
const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS }
const key = getKey(ip, scope)
const now = Date.now() const now = Date.now()
const entry = windows.get(ip) const entry = windows.get(key)
if (!entry || now - entry.start > WINDOW_MS) { if (!entry || now - entry.start > limit.windowMs) {
windows.set(ip, { start: now, count: 1 }) windows.set(key, { start: now, count: 1 })
return { allowed: true } return { allowed: true }
} }
entry.count += 1 entry.count += 1
if (entry.count > MAX_ATTEMPTS) { if (entry.count > limit.maxAttempts) {
const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000)
return { allowed: false, retryAfter } return { allowed: false, retryAfter }
} }
return { allowed: true } return { allowed: true }
} }
export function checkLoginRateLimit(ip) {
return checkRateLimit(ip, 'login')
}
export function checkCodeRequestRateLimit(ip) {
return checkRateLimit(ip, 'codeRequest')
}
export function checkCodeVerifyRateLimit(ip) {
return checkRateLimit(ip, 'codeVerify')
}
+24
View File
@@ -0,0 +1,24 @@
export async function registerSecurityHeaders(fastify) {
fastify.addHook('onSend', async (request, reply) => {
reply.header('X-Content-Type-Options', 'nosniff')
reply.header('X-Frame-Options', 'DENY')
reply.header('X-XSS-Protection', '0')
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
const cspDirectives = [
"default-src 'self'",
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
'frame-src https://*.yookassa.ru',
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
reply.header('Content-Security-Policy', cspDirectives)
})
}
@@ -1,7 +1,20 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, afterEach } from 'vitest'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
describe('OAuth — User model fields', () => { describe('OAuth — User model fields', () => {
const createdIds = []
afterEach(async () => {
for (const id of createdIds) {
try {
await prisma.user.delete({ where: { id } })
} catch {
// Already deleted by another test or cleanup — ignore
}
}
createdIds.length = 0
})
it('stores displayName and avatar fields on User model', async () => { it('stores displayName and avatar fields on User model', async () => {
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
@@ -11,10 +24,10 @@ describe('OAuth — User model fields', () => {
}, },
}) })
createdIds.push(user.id)
expect(user.displayName).toBe('Test User') expect(user.displayName).toBe('Test User')
expect(user.avatar).toBe('https://example.com/avatar.jpg') expect(user.avatar).toBe('https://example.com/avatar.jpg')
await prisma.user.delete({ where: { id: user.id } })
}) })
it('allows nullable fields', async () => { it('allows nullable fields', async () => {
@@ -24,9 +37,9 @@ describe('OAuth — User model fields', () => {
}, },
}) })
createdIds.push(user.id)
expect(user.displayName).toBeNull() expect(user.displayName).toBeNull()
expect(user.avatar).toBeNull() expect(user.avatar).toBeNull()
await prisma.user.delete({ where: { id: user.id } })
}) })
}) })
+7 -2
View File
@@ -1,5 +1,5 @@
import Fastify from 'fastify'
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import Fastify from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
@@ -84,7 +84,12 @@ describe('buildSseListeners', () => {
it('forwards order:statusChanged to matching userId', () => { it('forwards order:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write) const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }) eventBus.emit('order:statusChanged', {
orderId: 'o1',
userId: 'user-1',
oldStatus: 'PENDING_PAYMENT',
newStatus: 'PAID',
})
expect(write).toHaveBeenCalledTimes(1) expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
+19 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../lib/auth.js' } from '../lib/auth.js'
import { generateAvatar } from '../lib/generate-avatar.js' import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js' import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js'
export function mapUserForClient(user) { export function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
@@ -30,6 +30,15 @@ export async function registerAuthRoutes(fastify) {
const email = normalizeEmail(request.body?.email) const email = normalizeEmail(request.body?.email)
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
const ip = request.ip
const rate = checkCodeRequestRateLimit(ip)
if (!rate.allowed) {
return reply
.code(429)
.header('Retry-After', String(rate.retryAfter))
.send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` })
}
const code = await issueEmailCode({ email, purpose: 'login' }) const code = await issueEmailCode({ email, purpose: 'login' })
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
@@ -50,6 +59,15 @@ export async function registerAuthRoutes(fastify) {
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
const ip = request.ip
const rate = checkCodeVerifyRateLimit(ip)
if (!rate.allowed) {
return reply
.code(429)
.header('Retry-After', String(rate.retryAfter))
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
}
const ok = await verifyEmailCode({ email, purpose: 'login', code }) const ok = await verifyEmailCode({ email, purpose: 'login', code })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })