diff --git a/client/public/robots.txt b/client/public/robots.txt new file mode 100644 index 0000000..bcb400c --- /dev/null +++ b/client/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://любимыйкреатив.рф/sitemap.xml diff --git a/client/public/sitemap.xml b/client/public/sitemap.xml new file mode 100644 index 0000000..43a2a44 --- /dev/null +++ b/client/public/sitemap.xml @@ -0,0 +1,28 @@ + + + + https://любимыйкреатив.рф/ + 1.0 + daily + + + https://любимыйкреатив.рф/info + 0.8 + monthly + + + https://любимыйкреатив.рф/about + 0.7 + monthly + + + https://любимыйкреатив.рф/privacy + 0.5 + yearly + + + https://любимыйкреатив.рф/terms + 0.5 + yearly + + diff --git a/client/src/pages/info/ui/sections/PaymentSection.tsx b/client/src/pages/info/ui/sections/PaymentSection.tsx index 0e43938..e9f118f 100644 --- a/client/src/pages/info/ui/sections/PaymentSection.tsx +++ b/client/src/pages/info/ui/sections/PaymentSection.tsx @@ -7,7 +7,8 @@ const methods = [ { icon: , primary: 'Онлайн-оплата через ЮKassa', - secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', + secondary: + 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', }, { icon: , @@ -39,7 +40,8 @@ export function PaymentSection() { maxWidth: '56ch', }} > - Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате. + Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и + готов к оплате. diff --git a/client/src/pages/info/ui/sections/ReturnsSection.tsx b/client/src/pages/info/ui/sections/ReturnsSection.tsx index 63fc641..5561115 100644 --- a/client/src/pages/info/ui/sections/ReturnsSection.tsx +++ b/client/src/pages/info/ui/sections/ReturnsSection.tsx @@ -36,7 +36,9 @@ export function ReturnsSection() { lineHeight: 1.65, }} > - Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. + Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней + после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества + возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. @@ -58,7 +60,9 @@ export function ReturnsSection() { lineHeight: 1.65, }} > - Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы решим проблему в кратчайшие сроки. + Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, + устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы + решим проблему в кратчайшие сроки. diff --git a/client/src/shared/lib/order-status-data.ts b/client/src/shared/lib/order-status-data.ts index 5cb51b6..18c5c39 100644 --- a/client/src/shared/lib/order-status-data.ts +++ b/client/src/shared/lib/order-status-data.ts @@ -1,5 +1,12 @@ 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 { code: string @@ -37,7 +44,8 @@ export const ORDER_STATUS_DATA: ReadonlyArray = [ label: 'Отправлен', iconName: 'package', color: 'info', - description: 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.', + description: + 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.', }, { code: 'READY_FOR_PICKUP', diff --git a/client/src/shared/ui/OrderStatusChip.tsx b/client/src/shared/ui/OrderStatusChip.tsx index 5e50236..35bc79b 100644 --- a/client/src/shared/ui/OrderStatusChip.tsx +++ b/client/src/shared/ui/OrderStatusChip.tsx @@ -6,20 +6,47 @@ import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-stat const iconMap: Record = { banknote: ( - + ), 'check-circle': ( - + ), 'package-search': ( - + @@ -27,14 +54,32 @@ const iconMap: Record = { ), package: ( - + ), 'package-check': ( - + @@ -42,13 +87,31 @@ const iconMap: Record = { ), store: ( - + ), 'x-circle': ( - + diff --git a/scripts/SERVER_SETUP.md b/scripts/SERVER_SETUP.md index 78d1294..abf3600 100644 --- a/scripts/SERVER_SETUP.md +++ b/scripts/SERVER_SETUP.md @@ -169,3 +169,28 @@ curl http://127.0.0.1:3333/health ```bash 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`). diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100644 index 0000000..ec5a84c --- /dev/null +++ b/scripts/backup-db.sh @@ -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" diff --git a/scripts/craftshop-backup.service b/scripts/craftshop-backup.service new file mode 100644 index 0000000..9f8f3b9 --- /dev/null +++ b/scripts/craftshop-backup.service @@ -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 diff --git a/scripts/craftshop-backup.timer b/scripts/craftshop-backup.timer new file mode 100644 index 0000000..6655abc --- /dev/null +++ b/scripts/craftshop-backup.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Craftshop Database Backup Timer + +[Timer] +OnCalendar=*-*-* 00/6:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d8c0596..72c18a1 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/index.js b/server/src/index.js index cba933c..f7f8e0b 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -19,13 +19,14 @@ import { prisma } from './lib/prisma.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { registerAuth } from './plugins/auth.js' import { registerIpGate } from './plugins/ip-gate.js' +import { registerSecurityHeaders } from './plugins/security-headers.js' import { registerApiRoutes } from './routes/api.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' +import { registerSseRoutes } from './routes/sse.js' import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerUserAddressRoutes } from './routes/user-addresses.js' import { registerUserCartRoutes } from './routes/user-cart.js' -import { registerSseRoutes } from './routes/sse.js' import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' @@ -48,6 +49,44 @@ await fastify.register(cors, { 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, { secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', }) diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js index e95d905..cbc2680 100644 --- a/server/src/lib/generate-avatar.js +++ b/server/src/lib/generate-avatar.js @@ -1,5 +1,5 @@ -import { createAvatar } from '@dicebear/core' import { avataaars } from '@dicebear/collection' +import { createAvatar } from '@dicebear/core' const DEFAULT_STYLE = avataaars diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js index 7198aaa..0f09b1f 100644 --- a/server/src/lib/rate-limit.js +++ b/server/src/lib/rate-limit.js @@ -1,26 +1,51 @@ const windows = new Map() -const MAX_ATTEMPTS = 5 -const WINDOW_MS = 60_000 +const DEFAULT_MAX_ATTEMPTS = 5 +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(() => { const now = Date.now() 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() -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 entry = windows.get(ip) - if (!entry || now - entry.start > WINDOW_MS) { - windows.set(ip, { start: now, count: 1 }) + const entry = windows.get(key) + if (!entry || now - entry.start > limit.windowMs) { + windows.set(key, { start: now, count: 1 }) return { allowed: true } } entry.count += 1 - if (entry.count > MAX_ATTEMPTS) { - const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + if (entry.count > limit.maxAttempts) { + const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000) return { allowed: false, retryAfter } } 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') +} diff --git a/server/src/plugins/security-headers.js b/server/src/plugins/security-headers.js new file mode 100644 index 0000000..283ebc3 --- /dev/null +++ b/server/src/plugins/security-headers.js @@ -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) + }) +} diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js index e4dc118..d311075 100644 --- a/server/src/routes/__tests__/oauth-social.test.js +++ b/server/src/routes/__tests__/oauth-social.test.js @@ -1,7 +1,20 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { prisma } from '../../lib/prisma.js' 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 () => { const user = await prisma.user.create({ data: { @@ -11,10 +24,10 @@ describe('OAuth — User model fields', () => { }, }) + createdIds.push(user.id) + expect(user.displayName).toBe('Test User') expect(user.avatar).toBe('https://example.com/avatar.jpg') - - await prisma.user.delete({ where: { id: user.id } }) }) it('allows nullable fields', async () => { @@ -24,9 +37,9 @@ describe('OAuth — User model fields', () => { }, }) + createdIds.push(user.id) + expect(user.displayName).toBeNull() expect(user.avatar).toBeNull() - - await prisma.user.delete({ where: { id: user.id } }) }) }) diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js index 082f34d..da79a2e 100644 --- a/server/src/routes/__tests__/sse.test.js +++ b/server/src/routes/__tests__/sse.test.js @@ -1,5 +1,5 @@ -import Fastify from 'fastify' import { EventEmitter } from 'node:events' +import Fastify from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' @@ -84,7 +84,12 @@ describe('buildSseListeners', () => { it('forwards order:statusChanged to matching userId', () => { 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.mock.calls[0][0]).toContain('event: order:statusChanged') expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 54a36c3..cf7185b 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -10,7 +10,7 @@ import { } from '../lib/auth.js' import { generateAvatar } from '../lib/generate-avatar.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) { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) @@ -30,6 +30,15 @@ export async function registerAuthRoutes(fastify) { const email = normalizeEmail(request.body?.email) 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 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 (!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 }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })