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: 'Неверный или истёкший код' })