feat(#2): backend scaffold + auth (Express ESM, PG, JWT, bcrypt, rate-limit, закрытая регистрация)
Deploy / deploy (push) Failing after 4s
Deploy / deploy (push) Failing after 4s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c573e8bfe6
commit
be29d02cc0
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,18 @@
|
||||
# Backend — Node.js 20 (Alpine). Порт 4000 наружу не публикуется (только через nginx).
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Сначала манифесты — кэш слоёв.
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 4000
|
||||
|
||||
# Запуск от непривилегированного пользователя.
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
Generated
+1144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "nika-gallery-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Ника — Галерея & Аукцион: Express API (PostgreSQL 16, JWT)",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"check": "node --check src/index.js && node --check src/db/pool.js && node --check src/middleware/auth.js && node --check src/middleware/rateLimit.js && node --check src/routes/auth.js && node --check src/routes/works.js && node --check src/routes/auctions.js && node --check src/routes/subscriptions.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// PostgreSQL connection pool.
|
||||
// Единственный источник строки подключения — env DATABASE_URL. Не хардкодить.
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL не задан — задайте его в окружении (.env).');
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString,
|
||||
max: Number(process.env.PG_POOL_MAX ?? 10),
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
// Ошибка на idle-клиенте пула не должна ронять процесс.
|
||||
console.error('[db] неожиданная ошибка idle-клиента:', err.message);
|
||||
});
|
||||
|
||||
// Тонкая обёртка для параметризованных запросов (никакой конкатенации SQL).
|
||||
export function query(text, params) {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,103 @@
|
||||
// Ника — Галерея & Аукцион: точка входа Express API.
|
||||
// Порт наружу НЕ публикуется — доступ только через nginx (см. docker-compose).
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
import { pool } from './db/pool.js';
|
||||
import rateLimit from './middleware/rateLimit.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import worksRoutes from './routes/works.js';
|
||||
import auctionsRoutes from './routes/auctions.js';
|
||||
import subscriptionsRoutes from './routes/subscriptions.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT ?? 4000);
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// За nginx — доверяем первому прокси (корректный req.ip / X-Forwarded-For).
|
||||
app.set('trust proxy', 1);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Security-заголовки (без внешних зависимостей).
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('X-XSS-Protection', '0');
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
||||
if (isProd) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS — список origin из env (CORS_ORIGIN, через запятую). Дефолт — SITE_URL или всё в dev.
|
||||
const corsOrigins = (process.env.CORS_ORIGIN || process.env.SITE_URL || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
app.use(
|
||||
cors({
|
||||
origin: corsOrigins.length ? corsOrigins : true,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Health-check — не читает БД по умолчанию, но проверяет коннект если ?db=1.
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const body = { status: 'ok', service: 'nika-gallery-backend', time: new Date().toISOString() };
|
||||
if (req.query.db === '1') {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
body.db = 'ok';
|
||||
} catch (err) {
|
||||
body.status = 'degraded';
|
||||
body.db = 'down';
|
||||
return res.status(503).json({ ...body, error: err.message });
|
||||
}
|
||||
}
|
||||
return res.json(body);
|
||||
});
|
||||
|
||||
// Rate-limit только на аутентификацию (защита от подбора).
|
||||
app.use('/api/auth', rateLimit, authRoutes);
|
||||
|
||||
app.use('/api/works', worksRoutes);
|
||||
app.use('/api/auctions', auctionsRoutes);
|
||||
app.use('/api/subscriptions', subscriptionsRoutes);
|
||||
|
||||
// 404
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Не найдено.' });
|
||||
});
|
||||
|
||||
// Глобальный обработчик ошибок.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[error]', err);
|
||||
const status = err.status || 500;
|
||||
const payload = { error: isProd ? 'Внутренняя ошибка сервера.' : err.message };
|
||||
res.status(status).json(payload);
|
||||
});
|
||||
|
||||
// Запуск (только если файл запущен напрямую, а не импортирован в тесте).
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}` ||
|
||||
process.argv[1]?.endsWith('index.js');
|
||||
if (isMain) {
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`[nika] API слушает :${PORT} (env=${process.env.NODE_ENV ?? 'dev'})`);
|
||||
});
|
||||
|
||||
const shutdown = (sig) => {
|
||||
console.log(`[nika] ${sig} — останавливаемся…`);
|
||||
server.close(() => pool.end().finally(() => process.exit(0)));
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,98 @@
|
||||
// JWT-аутентификация и ролевой доступ.
|
||||
// Секреты только из env. Никаких дефолтных секретов в проде.
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? '';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET ?? JWT_SECRET;
|
||||
const ACCESS_TTL = process.env.JWT_ACCESS_TTL ?? '15m';
|
||||
const REFRESH_TTL = process.env.JWT_REFRESH_TTL ?? '30d';
|
||||
|
||||
// В проде запрещаем слабый/пустой секрет. В dev допускаем ослабленный.
|
||||
if (isProd && JWT_SECRET.length < 16) {
|
||||
throw new Error(
|
||||
'JWT_SECRET слишком короткий (<16 символов) при NODE_ENV=production. ' +
|
||||
'Задайте сильный секрет в окружении.'
|
||||
);
|
||||
}
|
||||
if (!isProd && JWT_SECRET.length < 16) {
|
||||
console.warn('[auth] JWT_SECRET короткий — допустимо только вне production.');
|
||||
}
|
||||
|
||||
// Список ролей из схемы БД (users.role CHECK).
|
||||
export const ROLES = ['owner', 'admin', 'buyer', 'subscriber'];
|
||||
// Привилегированные роли для админ-действий.
|
||||
export const ADMIN_ROLES = ['owner', 'admin'];
|
||||
|
||||
export function signAccessToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, role: user.role, email: user.email, name: user.name },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TTL }
|
||||
);
|
||||
}
|
||||
|
||||
export function signRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: REFRESH_TTL }
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token) {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET);
|
||||
}
|
||||
|
||||
function extractToken(req) {
|
||||
const header = req.headers.authorization || '';
|
||||
const [scheme, value] = header.split(' ');
|
||||
if (scheme === 'Bearer' && value) return value.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Требует валидный access-токен. Кладёт payload в req.user.
|
||||
export function authRequired(req, res, next) {
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Требуется авторизация.' });
|
||||
}
|
||||
try {
|
||||
req.user = verifyAccessToken(token);
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Недействительный или истёкший токен.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Требует, чтобы роль пользователя входила в список разрешённых.
|
||||
export function requireRole(...roles) {
|
||||
const allowed = roles.flat();
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Требуется авторизация.' });
|
||||
}
|
||||
if (!allowed.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Недостаточно прав.' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// Мягкая авторизация: если токен есть и валиден — заполняем req.user, иначе идём дальше.
|
||||
export function optionalAuth(req, _res, next) {
|
||||
const token = extractToken(req);
|
||||
if (token) {
|
||||
try {
|
||||
req.user = verifyAccessToken(token);
|
||||
} catch {
|
||||
// игнорируем — гость
|
||||
}
|
||||
}
|
||||
return next();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Простой in-memory ограничитель попыток — защита /api/auth от подбора.
|
||||
// По умолчанию: 10 попыток за 15 минут на IP. Без внешних зависимостей.
|
||||
|
||||
const WINDOW_MS = Number(process.env.AUTH_RATE_WINDOW_MS ?? 15 * 60 * 1000);
|
||||
const MAX_HITS = Number(process.env.AUTH_RATE_MAX ?? 10);
|
||||
|
||||
// key -> { count, resetAt }
|
||||
const buckets = new Map();
|
||||
|
||||
// Периодическая чистка протухших окон, чтобы Map не рос без предела.
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, b] of buckets) {
|
||||
if (b.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}, WINDOW_MS).unref?.();
|
||||
|
||||
function clientKey(req) {
|
||||
// За nginx реальный IP приходит в X-Forwarded-For (левый в цепочке).
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
if (typeof xff === 'string' && xff.length) {
|
||||
return xff.split(',')[0].trim();
|
||||
}
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
export function rateLimit(req, res, next) {
|
||||
const key = clientKey(req);
|
||||
const now = Date.now();
|
||||
let b = buckets.get(key);
|
||||
|
||||
if (!b || b.resetAt <= now) {
|
||||
b = { count: 0, resetAt: now + WINDOW_MS };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
|
||||
b.count += 1;
|
||||
const remaining = Math.max(0, MAX_HITS - b.count);
|
||||
res.setHeader('X-RateLimit-Limit', String(MAX_HITS));
|
||||
res.setHeader('X-RateLimit-Remaining', String(remaining));
|
||||
res.setHeader('X-RateLimit-Reset', String(Math.ceil(b.resetAt / 1000)));
|
||||
|
||||
if (b.count > MAX_HITS) {
|
||||
const retryAfter = Math.ceil((b.resetAt - now) / 1000);
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
return res.status(429).json({
|
||||
error: 'Слишком много попыток. Повторите позже.',
|
||||
retry_after_seconds: retryAfter,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export { sweep };
|
||||
export default rateLimit;
|
||||
@@ -0,0 +1,70 @@
|
||||
// Аукционы (primary/secondary). Заготовка под задачу #5 (+ WebSocket-ставки).
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/auctions — список аукционов, опционально фильтр по статусу.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 24, 100);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
const status = req.query.status;
|
||||
|
||||
const params = [limit, offset];
|
||||
let where = '';
|
||||
if (status) {
|
||||
params.push(String(status));
|
||||
where = `WHERE a.status = $${params.length}`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.work_id, a.type, a.start_price, a.current_price, a.reserve_price,
|
||||
a.min_step, a.start_at, a.end_at, a.status, a.winner_id, a.created_at,
|
||||
w.title AS work_title, w.slug AS work_slug
|
||||
FROM auctions a
|
||||
JOIN works w ON w.id = a.work_id
|
||||
${where}
|
||||
ORDER BY a.end_at ASC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
return res.json({ items: rows, limit, offset });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auctions/:id — карточка аукциона + последние ставки.
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.*, w.title AS work_title, w.slug AS work_slug
|
||||
FROM auctions a JOIN works w ON w.id = a.work_id
|
||||
WHERE a.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
const auction = rows[0];
|
||||
if (!auction) return res.status(404).json({ error: 'Аукцион не найден.' });
|
||||
|
||||
const bids = await pool.query(
|
||||
`SELECT id, user_id, amount, created_at
|
||||
FROM bids WHERE auction_id = $1 ORDER BY created_at DESC LIMIT 20`,
|
||||
[auction.id]
|
||||
);
|
||||
return res.json({ ...auction, bids: bids.rows });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Создание аукциона / приём ставки — задача #5.
|
||||
router.post('/', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
|
||||
res.status(501).json({ error: 'Не реализовано (задача #5).' });
|
||||
});
|
||||
router.post('/:id/bids', authRequired, (_req, res) => {
|
||||
res.status(501).json({ error: 'Не реализовано (задача #5).' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,208 @@
|
||||
// Аутентификация: регистрация (ЗАКРЫТАЯ) и вход.
|
||||
// Политика безопасности (инцидент 2026-08-05, открытая регистрация):
|
||||
// - первый пользователь в системе получает роль 'admin' (bootstrap);
|
||||
// - всех последующих заводит ТОЛЬКО админ по валидному access-токену;
|
||||
// - пароли только через bcrypt;
|
||||
// - все SQL — параметризованные (никакой конкатенации).
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db/pool.js';
|
||||
import {
|
||||
authRequired,
|
||||
requireRole,
|
||||
ADMIN_ROLES,
|
||||
ROLES,
|
||||
signAccessToken,
|
||||
signRefreshToken,
|
||||
verifyRefreshToken,
|
||||
} from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS ?? 12);
|
||||
|
||||
function sanitizeUser(u) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
telegram_id: u.telegram_id ?? null,
|
||||
created_at: u.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function countUsers() {
|
||||
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users');
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
// Внутреннее заведение пользователя. allowedRole — какие роли можно назначить.
|
||||
async function createUser({ email, name, password, role }) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (email, name, password, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, name, role, telegram_id, created_at`,
|
||||
[email.toLowerCase().trim(), name.trim(), hash, role]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// POST /api/auth/register
|
||||
// Первый пользователь → admin (без токена). Дальше — только админ по токену.
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, name, password, role: requestedRole } = req.body ?? {};
|
||||
|
||||
if (!email || !EMAIL_RE.test(String(email))) {
|
||||
return res.status(400).json({ error: 'Некорректный email.' });
|
||||
}
|
||||
if (!name || String(name).trim().length < 2) {
|
||||
return res.status(400).json({ error: 'Имя обязательно (мин. 2 символа).' });
|
||||
}
|
||||
if (!password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'Пароль обязателен (мин. 8 символов).' });
|
||||
}
|
||||
|
||||
const total = await countUsers();
|
||||
|
||||
// BOOTSTRAP: система пуста — создаём первого админа без авторизации.
|
||||
if (total === 0) {
|
||||
const user = await createUser({ email, name, password, role: 'admin' });
|
||||
return res.status(201).json({
|
||||
user: sanitizeUser(user),
|
||||
note: 'Первый пользователь создан с ролью admin.',
|
||||
});
|
||||
}
|
||||
|
||||
// Система не пуста — регистрация закрыта, нужен валидный админ-токен.
|
||||
// Оборачиваем authRequired + requireRole вручную, чтобы 401/403 были корректны.
|
||||
await new Promise((resolve, reject) => {
|
||||
authRequired(req, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
if (res.headersSent) return undefined;
|
||||
await new Promise((resolve, reject) => {
|
||||
requireRole(ADMIN_ROLES)(req, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
if (res.headersSent) return undefined;
|
||||
|
||||
// Роль назначает админ; по умолчанию 'buyer'. Валидируем по списку из схемы.
|
||||
let role = requestedRole ?? 'buyer';
|
||||
if (!ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: `Недопустимая роль. Допустимо: ${ROLES.join(', ')}.` });
|
||||
}
|
||||
|
||||
const user = await createUser({ email, name, password, role });
|
||||
return res.status(201).json({ user: sanitizeUser(user) });
|
||||
} catch (err) {
|
||||
if (err && err.code === '23505') {
|
||||
return res.status(409).json({ error: 'Пользователь с таким email уже существует.' });
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body ?? {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email и пароль обязательны.' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, password, role, telegram_id, created_at
|
||||
FROM users WHERE email = $1`,
|
||||
[String(email).toLowerCase().trim()]
|
||||
);
|
||||
const user = rows[0];
|
||||
|
||||
// Единый ответ на «нет юзера» и «неверный пароль» — не палим существование email.
|
||||
const ok = user ? await bcrypt.compare(String(password), user.password) : false;
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Неверный email или пароль.' });
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken(user);
|
||||
const refreshToken = signRefreshToken(user);
|
||||
|
||||
// Храним refresh-токен на пользователе (для последующего отзыва).
|
||||
await pool.query('UPDATE users SET refresh_token = $1, updated_at = now() WHERE id = $2', [
|
||||
refreshToken,
|
||||
user.id,
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
user: sanitizeUser(user),
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh — обмен refresh-токена на новый access-токен.
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const { refresh_token: refreshToken } = req.body ?? {};
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: 'refresh_token обязателен.' });
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = verifyRefreshToken(refreshToken);
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Недействительный refresh-токен.' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, role, telegram_id, created_at, refresh_token
|
||||
FROM users WHERE id = $1`,
|
||||
[payload.sub]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || user.refresh_token !== refreshToken) {
|
||||
return res.status(401).json({ error: 'Refresh-токен отозван или устарел.' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
access_token: signAccessToken(user),
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout — отзыв refresh-токена.
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await pool.query('UPDATE users SET refresh_token = NULL, updated_at = now() WHERE id = $1', [
|
||||
req.user.sub,
|
||||
]);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me — текущий пользователь.
|
||||
router.get('/me', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, role, telegram_id, created_at FROM users WHERE id = $1`,
|
||||
[req.user.sub]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
return res.json({ user: sanitizeUser(rows[0]) });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Подписки на рассылку. Заготовка под задачу #6 (double opt-in + рассылка).
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// POST /api/subscriptions — публичная подписка (double opt-in подтверждается в #6).
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { email, name } = req.body ?? {};
|
||||
if (!email || !EMAIL_RE.test(String(email))) {
|
||||
return res.status(400).json({ error: 'Некорректный email.' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO subscriptions (email, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (email) DO UPDATE SET name = COALESCE(EXCLUDED.name, subscriptions.name)
|
||||
RETURNING id, email, name, confirmed_at, created_at`,
|
||||
[String(email).toLowerCase().trim(), name ? String(name).trim() : null]
|
||||
);
|
||||
// Реальная отправка письма подтверждения — задача #6.
|
||||
return res.status(201).json({
|
||||
subscription: rows[0],
|
||||
note: 'Подписка принята. Письмо подтверждения будет отправлено (задача #6).',
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/subscriptions — список подписчиков (только админ).
|
||||
router.get('/', authRequired, requireRole(ADMIN_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, confirmed_at, created_at
|
||||
FROM subscriptions ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
return res.json({ items: rows, limit, offset });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,77 @@
|
||||
// Галерея работ. Заготовка под задачу #4 (CRUD + медиа).
|
||||
// Пока реализованы публичные GET-списки из works_extended + мутирующие заглушки под авторизацией.
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authRequired, requireRole, ADMIN_ROLES, optionalAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/works — публичный список опубликованных работ (агрегаты из works_extended).
|
||||
router.get('/', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const isAdmin = req.user && ADMIN_ROLES.includes(req.user.role);
|
||||
const limit = Math.min(Number(req.query.limit) || 24, 100);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
|
||||
// Гостям и покупателям — только published; админам — все статусы.
|
||||
const params = [limit, offset];
|
||||
let where = "WHERE status = 'published'";
|
||||
if (isAdmin) where = 'WHERE true';
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, title, slug, description, medium, dimensions, year, price,
|
||||
status, auction_mode, likes_count, comments_count, avg_rating,
|
||||
reviews_count, cover_url, cover_thumb, created_at
|
||||
FROM works_extended
|
||||
${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
return res.json({ items: rows, limit, offset });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/works/:slug — публичная карточка работы по slug.
|
||||
router.get('/:slug', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, title, slug, description, medium, dimensions, year, price,
|
||||
status, auction_mode, likes_count, comments_count, avg_rating,
|
||||
reviews_count, cover_url, cover_thumb, created_at
|
||||
FROM works_extended WHERE slug = $1`,
|
||||
[req.params.slug]
|
||||
);
|
||||
const work = rows[0];
|
||||
if (!work) return res.status(404).json({ error: 'Работа не найдена.' });
|
||||
|
||||
const isAdmin = req.user && ADMIN_ROLES.includes(req.user.role);
|
||||
if (work.status !== 'published' && !isAdmin) {
|
||||
return res.status(404).json({ error: 'Работа не найдена.' });
|
||||
}
|
||||
|
||||
const media = await pool.query(
|
||||
`SELECT id, url, thumb_url, type, sort_order
|
||||
FROM media WHERE work_id = $1 ORDER BY sort_order`,
|
||||
[work.id]
|
||||
);
|
||||
return res.json({ ...work, media: media.rows });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Мутирующие маршруты — под авторизацией и админ-ролью. Реализация — задача #4.
|
||||
router.post('/', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
|
||||
res.status(501).json({ error: 'Не реализовано (задача #4).' });
|
||||
});
|
||||
router.patch('/:id', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
|
||||
res.status(501).json({ error: 'Не реализовано (задача #4).' });
|
||||
});
|
||||
router.delete('/:id', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
|
||||
res.status(501).json({ error: 'Не реализовано (задача #4).' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user