diff --git a/.env.example b/.env.example index 887b1ce..475a0e0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json index 8e11c33..2ebd92c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/index.js b/backend/src/index.js index f285667..ce25d02 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/club.js b/backend/src/routes/club.js new file mode 100644 index 0000000..6fc0e44 --- /dev/null +++ b/backend/src/routes/club.js @@ -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; diff --git a/backend/src/routes/oauth.js b/backend/src/routes/oauth.js new file mode 100644 index 0000000..5c35c61 --- /dev/null +++ b/backend/src/routes/oauth.js @@ -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; diff --git a/db/migrations/club_access.sql b/db/migrations/club_access.sql new file mode 100644 index 0000000..1bb32b4 --- /dev/null +++ b/db/migrations/club_access.sql @@ -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;