Merge branch 'site-fixes'
This commit is contained in:
@@ -17,6 +17,9 @@ JWT_SECRET=замените-на-секрет-jwt
|
||||
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||
# CORS_ORIGIN=http://127.0.0.1:5173
|
||||
|
||||
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
|
||||
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
|
||||
|
||||
# Публичные URL для OAuth redirect (локально обычно так):
|
||||
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@ import { createNotificationQueue } from './lib/notifications/queue.js'
|
||||
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 { registerApiRoutes } from './routes/api.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||
@@ -89,6 +90,7 @@ const notificationQueue = createNotificationQueue()
|
||||
fastify.decorate('eventBus', eventBus)
|
||||
fastify.decorate('notificationQueue', notificationQueue)
|
||||
|
||||
await registerIpGate(fastify)
|
||||
registerAuth(fastify)
|
||||
await registerUserAddressRoutes(fastify)
|
||||
await registerUserCartRoutes(fastify)
|
||||
|
||||
@@ -10,10 +10,10 @@ export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
||||
DRAFT: 'Черновик',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAID: 'Оплачен',
|
||||
IN_PROGRESS: 'В работе',
|
||||
IN_PROGRESS: 'Подготовка к отправке',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||
SHIPPED: 'Отправлен',
|
||||
DONE: 'Выполнен',
|
||||
DONE: 'Завершён',
|
||||
CANCELLED: 'Отменён',
|
||||
}
|
||||
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Fastify from 'fastify'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { build403Html, registerIpGate } from '../ip-gate.js'
|
||||
|
||||
function buildApp() {
|
||||
const app = Fastify({ logger: false, trustProxy: true })
|
||||
app.get('/test', async () => ({ ok: true }))
|
||||
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
|
||||
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
|
||||
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
|
||||
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
describe('registerIpGate', () => {
|
||||
let app
|
||||
const originalIps = process.env.SITE_ACCESS_IPS
|
||||
|
||||
beforeEach(async () => {
|
||||
app = buildApp()
|
||||
await registerIpGate(app)
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
if (originalIps === undefined) {
|
||||
delete process.env.SITE_ACCESS_IPS
|
||||
} else {
|
||||
process.env.SITE_ACCESS_IPS = originalIps
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
|
||||
delete process.env.SITE_ACCESS_IPS
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('пропускает запрос с разрешённого IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает запрос с IPv6-mapped разрешённого IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('блокирует запрос с неразрешённого IP (403)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/)
|
||||
expect(res.body).toContain('Любимый Креатив')
|
||||
expect(res.body).toContain('9.9.9.9')
|
||||
})
|
||||
|
||||
it('build403Html показывает "не определён" когда IP не передан', () => {
|
||||
const html = build403Html()
|
||||
expect(html).toContain('не определён')
|
||||
expect(html).toContain('Любимый Креатив')
|
||||
})
|
||||
|
||||
it('build403Html показывает переданный IP', () => {
|
||||
const html = build403Html('9.9.9.9')
|
||||
expect(html).toContain('9.9.9.9')
|
||||
expect(html).not.toContain('не определён')
|
||||
})
|
||||
|
||||
it('build403Html с пустой строкой показывает "не определён"', () => {
|
||||
const html = build403Html('')
|
||||
expect(html).toContain('не определён')
|
||||
})
|
||||
|
||||
it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test' })
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.body).toContain('127.0.0.1')
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/webhooks/yookassa',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/oauth/vk/callback',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/oauth/yandex/callback',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/admin/notifications/telegram/webhook',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('корректно тримит пробелы в списке IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '5.6.7.8',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает если после трима список IP пуст', async () => {
|
||||
process.env.SITE_ACCESS_IPS = ' , , '
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('путь с query-параметрами проверяется корректно', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test?foo=bar',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('исключённый путь с query-параметрами тоже пропускается', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/webhooks/yookassa?foo=bar',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
const EXCLUDED_PATHS = [
|
||||
'/api/auth/oauth/vk/callback',
|
||||
'/api/auth/oauth/yandex/callback',
|
||||
'/api/webhooks/yookassa',
|
||||
'/api/admin/notifications/telegram/webhook',
|
||||
]
|
||||
|
||||
function normalizeIp(ip) {
|
||||
if (ip && ip.startsWith('::ffff:')) {
|
||||
return ip.slice(7)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
export function build403Html(ip) {
|
||||
const safeIp = ip || 'не определён'
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Любимый Креатив</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #faf8f5;
|
||||
color: #3d322b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e0d8;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
|
||||
}
|
||||
.card h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: #4a3a2e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card .tagline {
|
||||
font-size: 14px;
|
||||
color: #8c8177;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card .status {
|
||||
font-size: 16px;
|
||||
color: #6b5e52;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card .ip {
|
||||
font-size: 12px;
|
||||
color: #b8a99b;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Любимый Креатив</h1>
|
||||
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
|
||||
<p class="status">Сайт находится в разработке и скоро будет доступен</p>
|
||||
<p class="ip">Ваш IP: ${safeIp}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export async function registerIpGate(fastify) {
|
||||
fastify.addHook('onRequest', async (request, reply) => {
|
||||
const allowed = process.env.SITE_ACCESS_IPS
|
||||
if (!allowed) return
|
||||
|
||||
const allowedIps = allowed
|
||||
.split(',')
|
||||
.map((s) => normalizeIp(s.trim()))
|
||||
.filter(Boolean)
|
||||
|
||||
if (allowedIps.length === 0) return
|
||||
|
||||
const urlPath = request.url.split('?')[0]
|
||||
|
||||
if (EXCLUDED_PATHS.includes(urlPath)) return
|
||||
|
||||
if (allowedIps.includes(normalizeIp(request.ip))) return
|
||||
|
||||
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
||||
})
|
||||
}
|
||||
@@ -204,4 +204,18 @@ export async function registerAuthRoutes(fastify) {
|
||||
})
|
||||
return { user: mapUserForClient(updated) }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
|
||||
const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP']
|
||||
|
||||
const activeOrders = await prisma.order.findMany({
|
||||
where: { userId, status: { in: ACTIVE_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await prisma.user.delete({ where: { id: userId } })
|
||||
return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user