feat(club): backend закрытого клуба + вход через Яндекс ID

Миграция club_access.sql (access_requests, users.club_tier/yandex_id/
avatar_url, password NULLable, works.club_only). OAuth-роуты Яндекса
(/api/auth/yandex[/callback]) с CSRF-state и мягкой 503 без ключей.
Клуб: /api/club/tiers|request|my-request|gallery + админ requests/
approve/decline. Rate-limit на /api/auth сохранён.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
german
2026-08-05 21:49:02 +03:00
co-authored by Claude Opus 4.8
parent ad35d4e992
commit 8291d0f725
6 changed files with 525 additions and 3 deletions
+9
View File
@@ -18,6 +18,15 @@ TELEGRAM_BOT_TOKEN=bot:token
TELEGRAM_CHANNEL_ID=@nika_channel
OWNER_TELEGRAM_ID=123456789
# Yandex ID OAuth (вход через Яндекс). Создать приложение: https://oauth.yandex.ru/client/new
# Права: «Доступ к адресу электронной почты» (login:email) + «Доступ к логину, имени и полу» (login:info).
# Redirect URI в кабинете Яндекса должен ТОЧНО совпадать с YANDEX_REDIRECT_URI ниже.
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
YANDEX_REDIRECT_URI=https://nika-gallery.ru/api/auth/yandex/callback
# Куда вернуть пользователя с токеном (обычно = SITE_URL). Токен приходит в hash: /club#token=...
FRONTEND_URL=https://nika-gallery.ru
# App
SITE_URL=https://nika-gallery.ru
PORT=4000
+1 -1
View File
@@ -10,7 +10,7 @@
"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"
"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/oauth.js && node --check src/routes/club.js && node --check src/routes/works.js && node --check src/routes/auctions.js && node --check src/routes/subscriptions.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+10 -2
View File
@@ -7,6 +7,8 @@ import cors from 'cors';
import { pool } from './db/pool.js';
import rateLimit from './middleware/rateLimit.js';
import authRoutes from './routes/auth.js';
import oauthRoutes from './routes/oauth.js';
import clubRoutes from './routes/club.js';
import worksRoutes from './routes/works.js';
import auctionsRoutes from './routes/auctions.js';
import subscriptionsRoutes from './routes/subscriptions.js';
@@ -63,8 +65,14 @@ app.get('/api/health', async (req, res) => {
return res.json(body);
});
// Rate-limit только на аутентификацию (защита от подбора).
app.use('/api/auth', rateLimit, authRoutes);
// Rate-limit только на аутентификацию (защита от подбора). Один раз на префикс —
// покрывает и локальный вход, и OAuth-роуты (вход через Яндекс), без двойного счёта.
app.use('/api/auth', rateLimit);
app.use('/api/auth', authRoutes);
app.use('/api/auth', oauthRoutes);
// Закрытый клуб (заявки, модерация, закрытая галерея).
app.use('/api/club', clubRoutes);
app.use('/api/works', worksRoutes);
app.use('/api/auctions', auctionsRoutes);
+218
View File
@@ -0,0 +1,218 @@
// Закрытый клуб: заявки на уровни доступа, модерация админом, закрытая галерея.
//
// Уровни (club_tier): vip / collector / partner / photographer / organizer / press.
// Публичные: GET /api/club/tiers — описание уровней.
// Авторизованные:
// POST /api/club/request — подать заявку на уровень.
// GET /api/club/my-request — статус своей заявки + текущий уровень.
// GET /api/club/gallery — закрытая галерея (только для членов клуба).
// Админ: GET /api/club/requests — список заявок (по умолч. pending).
// POST /api/club/requests/:id/approve — одобрить (ставит users.club_tier).
// POST /api/club/requests/:id/decline — отклонить.
import { Router } from 'express';
import { pool } from '../db/pool.js';
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
const router = Router();
// Канон уровней клуба (совпадает с CHECK в миграции + витриной фронта).
export const CLUB_TIERS = [
{ id: 'vip', title: 'VIP', desc: 'Полный доступ ко всем закрытым работам, приоритет на аукционах, личные превью.' },
{ id: 'collector', title: 'Коллекционеры', desc: 'Ранний доступ к новым работам и коллекционным сериям, консультации по подбору.' },
{ id: 'partner', title: 'Партнёры', desc: 'Совместные проекты и коллаборации, специальные условия сотрудничества.' },
{ id: 'photographer', title: 'Фотографы', desc: 'Доступ к backstage-материалам и совместным съёмкам body art.' },
{ id: 'organizer', title: 'Организаторы', desc: 'Работы и материалы для выставок, перформансов и мероприятий.' },
{ id: 'press', title: 'Пресса', desc: 'Пресс-материалы, изображения в высоком разрешении, интервью.' },
];
const TIER_IDS = CLUB_TIERS.map((t) => t.id);
// GET /api/club/tiers — публичное описание уровней (для витрины «Закрытый клуб»).
router.get('/tiers', (_req, res) => {
res.json({ tiers: CLUB_TIERS });
});
// POST /api/club/request — авторизованный пользователь подаёт заявку на уровень.
router.post('/request', authRequired, async (req, res, next) => {
try {
const { tier, note } = req.body ?? {};
if (!TIER_IDS.includes(tier)) {
return res.status(400).json({ error: `Недопустимый уровень. Допустимо: ${TIER_IDS.join(', ')}.` });
}
const cleanNote = note ? String(note).slice(0, 2000) : null;
// Уже член клуба этого уровня?
const { rows: urows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [
req.user.sub,
]);
if (!urows[0]) return res.status(404).json({ error: 'Пользователь не найден.' });
if (urows[0].club_tier === tier) {
return res.status(409).json({ error: 'У вас уже есть этот уровень доступа.' });
}
// Одна активная заявка на пользователя (частичный uniq-индекс это гарантирует).
try {
const { rows } = await pool.query(
`INSERT INTO access_requests (user_id, tier, note)
VALUES ($1, $2, $3)
RETURNING id, tier, status, note, created_at`,
[req.user.sub, tier, cleanNote]
);
return res.status(201).json({ request: rows[0] });
} catch (err) {
if (err && err.code === '23505') {
return res.status(409).json({ error: 'У вас уже есть заявка на рассмотрении.' });
}
throw err;
}
} catch (err) {
return next(err);
}
});
// GET /api/club/my-request — своя заявка (последняя) + текущий уровень.
router.get('/my-request', authRequired, async (req, res, next) => {
try {
const { rows: urows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [
req.user.sub,
]);
const { rows } = await pool.query(
`SELECT id, tier, status, note, admin_note, created_at, reviewed_at
FROM access_requests WHERE user_id = $1
ORDER BY created_at DESC LIMIT 1`,
[req.user.sub]
);
return res.json({
club_tier: urows[0]?.club_tier ?? null,
request: rows[0] ?? null,
});
} catch (err) {
return next(err);
}
});
// GET /api/club/gallery — закрытая галерея. Только для членов клуба (любой club_tier) и админов.
router.get('/gallery', authRequired, async (req, res, next) => {
try {
const isAdmin = ADMIN_ROLES.includes(req.user.role);
let hasTier = isAdmin;
if (!hasTier) {
const { rows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [req.user.sub]);
hasTier = Boolean(rows[0]?.club_tier);
}
if (!hasTier) {
return res.status(403).json({ error: 'Доступ только для членов закрытого клуба.' });
}
const limit = Math.min(Number(req.query.limit) || 24, 100);
const offset = Math.max(Number(req.query.offset) || 0, 0);
// Клубные работы: works.club_only = true. Агрегаты берём из works_extended по id.
const { rows } = await pool.query(
`SELECT we.id, we.title, we.slug, we.description, we.medium, we.dimensions,
we.year, we.price, we.status, we.auction_mode, we.likes_count,
we.comments_count, we.avg_rating, we.reviews_count,
we.cover_url, we.cover_thumb, we.created_at
FROM works_extended we
JOIN works w ON w.id = we.id
WHERE w.club_only = true
AND (we.status = 'published' OR $3 = true)
ORDER BY we.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset, isAdmin]
);
return res.json({ items: rows, limit, offset });
} catch (err) {
return next(err);
}
});
// GET /api/club/requests — список заявок (админ). ?status=pending|approved|declined|all
router.get('/requests', authRequired, requireRole(ADMIN_ROLES), async (req, res, next) => {
try {
const status = String(req.query.status || 'pending');
const params = [];
let where = '';
if (status !== 'all') {
if (!['pending', 'approved', 'declined'].includes(status)) {
return res.status(400).json({ error: 'Недопустимый status.' });
}
params.push(status);
where = 'WHERE ar.status = $1';
}
const { rows } = await pool.query(
`SELECT ar.id, ar.tier, ar.status, ar.note, ar.admin_note,
ar.created_at, ar.reviewed_at,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
u.avatar_url AS user_avatar, u.club_tier AS user_current_tier
FROM access_requests ar
JOIN users u ON u.id = ar.user_id
${where}
ORDER BY ar.created_at DESC
LIMIT 200`,
params
);
return res.json({ items: rows });
} catch (err) {
return next(err);
}
});
// Общий обработчик решения по заявке.
async function decide(req, res, next, decision) {
const client = await pool.connect();
try {
const { id } = req.params;
const adminNote = req.body?.admin_note ? String(req.body.admin_note).slice(0, 2000) : null;
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT id, user_id, tier, status FROM access_requests WHERE id = $1 FOR UPDATE`,
[id]
);
const reqRow = rows[0];
if (!reqRow) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Заявка не найдена.' });
}
if (reqRow.status !== 'pending') {
await client.query('ROLLBACK');
return res.status(409).json({ error: `Заявка уже обработана (${reqRow.status}).` });
}
const newStatus = decision === 'approve' ? 'approved' : 'declined';
await client.query(
`UPDATE access_requests
SET status = $1, admin_note = $2, reviewed_by = $3, reviewed_at = now(), updated_at = now()
WHERE id = $4`,
[newStatus, adminNote, req.user.sub, id]
);
// При одобрении — выставляем уровень пользователю.
if (decision === 'approve') {
await client.query('UPDATE users SET club_tier = $1, updated_at = now() WHERE id = $2', [
reqRow.tier,
reqRow.user_id,
]);
}
await client.query('COMMIT');
return res.json({ ok: true, id, status: newStatus, tier: reqRow.tier });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
return next(err);
} finally {
client.release();
}
}
// POST /api/club/requests/:id/approve — одобрить (ставит users.club_tier).
router.post('/requests/:id/approve', authRequired, requireRole(ADMIN_ROLES), (req, res, next) =>
decide(req, res, next, 'approve')
);
// POST /api/club/requests/:id/decline — отклонить.
router.post('/requests/:id/decline', authRequired, requireRole(ADMIN_ROLES), (req, res, next) =>
decide(req, res, next, 'decline')
);
export default router;
+220
View File
@@ -0,0 +1,220 @@
// Вход через Яндекс ID (OAuth 2.0 authorization code).
// По образцу рабочего Heritage (routes/oauth.js): редирект → callback → JWT в hash фронта.
//
// Поток:
// GET /api/auth/yandex → редирект на oauth.yandex.ru/authorize (state = короткоживущий JWT, CSRF).
// GET /api/auth/yandex/callback → обмен code на access_token, профиль login.yandex.ru/info,
// поиск/создание пользователя по yandex_id/email,
// выдача нашего JWT, редирект на FRONTEND_URL/#token=...
//
// Ключи НЕ хардкодятся — берутся из env. Если не заданы — понятная 503, процесс НЕ падает.
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { pool } from '../db/pool.js';
import { signAccessToken, signRefreshToken } from '../middleware/auth.js';
const router = Router();
const AUTHORIZE_URL = 'https://oauth.yandex.ru/authorize';
const TOKEN_URL = 'https://oauth.yandex.ru/token';
const INFO_URL = 'https://login.yandex.ru/info';
// Секрет для подписи state (используем общий JWT_SECRET — он уже валидируется в middleware/auth).
const STATE_SECRET = process.env.JWT_SECRET ?? '';
const STATE_TTL = '10m';
function config() {
return {
clientId: process.env.YANDEX_CLIENT_ID || '',
clientSecret: process.env.YANDEX_CLIENT_SECRET || '',
// REDIRECT_URI должен совпадать с зарегистрированным в кабинете oauth.yandex.ru.
redirectUri: process.env.YANDEX_REDIRECT_URI || '',
// Куда вернуть пользователя с токеном (фронт).
frontendUrl: (process.env.FRONTEND_URL || process.env.SITE_URL || '').replace(/\/$/, ''),
};
}
// Настроен ли Яндекс-вход? (иначе — 503, но сервер живёт).
function isConfigured(c) {
return Boolean(c.clientId && c.clientSecret && c.redirectUri);
}
function frontendRedirect(c) {
// Фолбэк на локальный фронт в dev, чтобы редирект не был битым.
return c.frontendUrl || 'http://localhost:3000';
}
// GET /api/auth/yandex — старт авторизации.
router.get('/yandex', (req, res) => {
const c = config();
if (!isConfigured(c)) {
return res.status(503).json({
error: 'Вход через Яндекс временно недоступен (не настроены ключи YANDEX_*).',
});
}
if (!STATE_SECRET) {
return res.status(503).json({ error: 'Сервер не настроен (нет JWT_SECRET для state).' });
}
// CSRF-защита: подписанный короткоживущий state.
const state = jwt.sign({ n: Math.random().toString(36).slice(2) }, STATE_SECRET, {
expiresIn: STATE_TTL,
});
const params = new URLSearchParams({
response_type: 'code',
client_id: c.clientId,
redirect_uri: c.redirectUri,
state,
// login:email — почта, login:info — имя/аватар/id.
scope: 'login:email login:info',
force_confirm: 'yes',
});
return res.redirect(`${AUTHORIZE_URL}?${params.toString()}`);
});
// GET /api/auth/yandex/callback — приём кода, обмен, вход/создание.
router.get('/yandex/callback', async (req, res, next) => {
const c = config();
const front = frontendRedirect(c);
try {
if (!isConfigured(c) || !STATE_SECRET) {
return res.status(503).json({ error: 'Вход через Яндекс не настроен.' });
}
const { code, state, error: oauthError } = req.query;
if (oauthError) {
return res.redirect(`${front}/club?error=${encodeURIComponent(String(oauthError))}`);
}
if (!code || !state) {
return res.redirect(`${front}/club?error=no_code`);
}
// Проверяем state (CSRF).
try {
jwt.verify(String(state), STATE_SECRET);
} catch {
return res.redirect(`${front}/club?error=bad_state`);
}
// 1) Обмен кода на access_token Яндекса.
const tokenResp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: String(code),
client_id: c.clientId,
client_secret: c.clientSecret,
redirect_uri: c.redirectUri,
}),
});
if (!tokenResp.ok) {
const t = await tokenResp.text().catch(() => '');
console.error('[yandex] token exchange failed:', tokenResp.status, t);
return res.redirect(`${front}/club?error=token_exchange`);
}
const tokenJson = await tokenResp.json();
const yToken = tokenJson.access_token;
if (!yToken) {
return res.redirect(`${front}/club?error=no_token`);
}
// 2) Профиль пользователя.
const infoResp = await fetch(INFO_URL, {
headers: { Authorization: `OAuth ${yToken}` },
});
if (!infoResp.ok) {
console.error('[yandex] info failed:', infoResp.status);
return res.redirect(`${front}/club?error=profile`);
}
const info = await infoResp.json();
const yandexId = String(info.id);
const email = (info.default_email || (info.emails && info.emails[0]) || '')
.toString()
.toLowerCase()
.trim();
const name =
info.real_name || info.display_name || info.first_name || info.login || 'Пользователь Яндекс';
const avatar =
info.default_avatar_id && !info.is_avatar_empty
? `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
: null;
// 3) Поиск/создание пользователя (по yandex_id, затем по email — связываем аккаунты).
const user = await findOrCreateYandexUser({ yandexId, email, name, avatar });
// 4) Наши токены. Сохраняем refresh для последующего отзыва.
const accessToken = signAccessToken(user);
const refreshToken = signRefreshToken(user);
await pool.query('UPDATE users SET refresh_token = $1, updated_at = now() WHERE id = $2', [
refreshToken,
user.id,
]);
// 5) Редирект на фронт: токен в hash (не попадает в логи/Referer сервера).
const hash = new URLSearchParams({
token: accessToken,
refresh: refreshToken,
});
return res.redirect(`${front}/club#${hash.toString()}`);
} catch (err) {
console.error('[yandex] callback error:', err);
// Не показываем стек пользователю — мягкий редирект.
if (!res.headersSent) {
return res.redirect(`${front}/club?error=internal`);
}
return next(err);
}
});
// Поиск по yandex_id → по email (линковка) → создание нового.
async function findOrCreateYandexUser({ yandexId, email, name, avatar }) {
// 1. По yandex_id.
let { rows } = await pool.query(
`SELECT id, email, name, role, club_tier, telegram_id, created_at
FROM users WHERE yandex_id = $1`,
[yandexId]
);
if (rows[0]) {
// Обновим аватар/имя при изменении.
await pool.query(
`UPDATE users SET avatar_url = COALESCE($1, avatar_url), updated_at = now() WHERE id = $2`,
[avatar, rows[0].id]
);
return rows[0];
}
// 2. По email — привязываем Яндекс к существующему аккаунту.
if (email) {
({ rows } = await pool.query(
`SELECT id, email, name, role, club_tier, telegram_id, created_at
FROM users WHERE email = $1`,
[email]
));
if (rows[0]) {
await pool.query(
`UPDATE users
SET yandex_id = $1, avatar_url = COALESCE($2, avatar_url), updated_at = now()
WHERE id = $3`,
[yandexId, avatar, rows[0].id]
);
return rows[0];
}
}
// 3. Новый пользователь. Роль 'buyer', пароль NULL (вход только через Яндекс).
// Если email пуст (пользователь скрыл почту) — синтезируем плейсхолдер.
const finalEmail = email || `yandex_${yandexId}@yandex.local`;
const insert = await pool.query(
`INSERT INTO users (email, name, password, role, yandex_id, avatar_url)
VALUES ($1, $2, NULL, 'buyer', $3, $4)
RETURNING id, email, name, role, club_tier, telegram_id, created_at`,
[finalEmail, name, yandexId, avatar]
);
return insert.rows[0];
}
export default router;
+67
View File
@@ -0,0 +1,67 @@
-- nika-gallery: миграция «Закрытый клуб» + вход через Яндекс ID.
-- Аддитивно к db/schema.sql. Идемпотентна (IF NOT EXISTS / IF EXISTS).
-- Задача: feat/club-yandex-auth (Claude Opus 4.8, 2026-08-05).
--
-- Что добавляет:
-- 1. users: club_tier (уровень клуба), yandex_id (uniq), avatar_url;
-- password делаем NULLable (вход только через Яндекс — без локального пароля).
-- 2. access_requests: заявки в клуб с модерацией (pending/approved/declined).
-- 3. works: флаг club_only (работа видна только членам клуба).
BEGIN;
-- ── users: поля клуба и Яндекс ID ───────────────────────────────
ALTER TABLE users ADD COLUMN IF NOT EXISTS club_tier TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS yandex_id TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
-- Допустимые уровни клуба (nullable — большинство пользователей вне клуба).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_club_tier_check'
) THEN
ALTER TABLE users ADD CONSTRAINT users_club_tier_check
CHECK (club_tier IS NULL OR club_tier IN
('vip','collector','partner','photographer','organizer','press'));
END IF;
END $$;
-- yandex_id уникален, но только среди заполненных (частичный уникальный индекс).
CREATE UNIQUE INDEX IF NOT EXISTS uniq_users_yandex_id
ON users(yandex_id) WHERE yandex_id IS NOT NULL;
-- Пароль больше не обязателен: пользователь может входить только через Яндекс.
-- (Локальная регистрация всё равно требует пароль на уровне API.)
ALTER TABLE users ALTER COLUMN password DROP NOT NULL;
-- ── access_requests: заявки в клуб с модерацией ─────────────────
CREATE TABLE IF NOT EXISTS access_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tier TEXT NOT NULL CHECK (tier IN
('vip','collector','partner','photographer','organizer','press')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','declined')),
note TEXT, -- сообщение заявителя «о себе»
admin_note TEXT, -- комментарий модератора (опц.)
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_access_requests_status
ON access_requests(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_access_requests_user
ON access_requests(user_id, created_at DESC);
-- Не более одной активной (pending) заявки на пользователя.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_access_requests_pending
ON access_requests(user_id) WHERE status = 'pending';
-- ── works: закрытые (клубные) работы ────────────────────────────
ALTER TABLE works ADD COLUMN IF NOT EXISTS club_only BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_works_club_only ON works(club_only) WHERE club_only = true;
COMMIT;