diff --git a/.env.example b/.env.example index 887b1ce..9fd9ce8 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret # Email SMTP_HOST=smtp.example.com SMTP_PORT=587 -SMTP_USER=noreply@nika-gallery.ru +SMTP_USER=noreply@1nika.ru SMTP_PASS=smtp-password OWNER_EMAIL=nika@example.com @@ -18,12 +18,30 @@ 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 ниже. +# Значения client_id/secret для 1nika.ru — в YandexDisk/servaki.online/YANDEX-OAUTH-APPS.secret.txt [NIKA ART]. +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= +YANDEX_REDIRECT_URI=https://1nika.ru/api/auth/yandex/callback +# Куда вернуть пользователя с токеном (обычно = SITE_URL). Токен приходит в hash: /club#token=... +FRONTEND_URL=https://1nika.ru + # App -SITE_URL=https://nika-gallery.ru +SITE_URL=https://1nika.ru PORT=4000 NODE_ENV=production UPLOAD_DIR=/app/uploads +# Bot adapter (единый бот флота @servaki_online_bot → ручки /api/bot/*) +# Сервис-токен: любая длинная случайная строка, генерируется при деплое, тот же +# токен прописывается в реестре бота (sites.api_token). НЕ коммитить реальное значение. +# openssl rand -hex 32 +BOT_API_TOKEN= +# Ключ сайта в реестре бота (возвращается в GET /api/bot/health). +BOT_SITE_KEY=nika + # Frontend -NEXT_PUBLIC_API_URL=https://nika-gallery.ru/api -NEXT_PUBLIC_WS_URL=wss://nika-gallery.ru/ws +NEXT_PUBLIC_API_URL=https://1nika.ru/api +NEXT_PUBLIC_WS_URL=wss://1nika.ru/ws diff --git a/backend/package-lock.json b/backend/package-lock.json index a8c4548..8b193d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.1", "pg": "^8.12.0" }, "engines": { @@ -32,6 +33,12 @@ "node": ">= 0.6" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -74,6 +81,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -112,6 +136,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -665,6 +704,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -903,6 +961,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1082,6 +1154,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1104,6 +1193,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1113,6 +1208,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 8e11c33..b4ff1a1 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/middleware/botAuth.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 && node --check src/routes/bot.js" }, "dependencies": { "bcryptjs": "^2.4.3", @@ -18,6 +18,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.1", "pg": "^8.12.0" } } diff --git a/backend/src/index.js b/backend/src/index.js index f285667..c710c48 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,9 +7,12 @@ 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'; +import botRoutes from './routes/bot.js'; const app = express(); const PORT = Number(process.env.PORT ?? 4000); @@ -63,13 +66,23 @@ 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); app.use('/api/subscriptions', subscriptionsRoutes); +// Сайт-адаптер единого бота флота (@servaki_online_bot). Сервис-токен BOT_API_TOKEN. +// НЕ под /api/auth rate-limit: /health опрашивается ботом на живость. +app.use('/api/bot', botRoutes); + // 404 app.use((_req, res) => { res.status(404).json({ error: 'Не найдено.' }); diff --git a/backend/src/middleware/botAuth.js b/backend/src/middleware/botAuth.js new file mode 100644 index 0000000..9a41040 --- /dev/null +++ b/backend/src/middleware/botAuth.js @@ -0,0 +1,31 @@ +// Аутентификация единого бота флота (@servaki_online_bot) для ручек /api/bot/*. +// Контракт: Authorization: Bearer . Токен только из env, дефолта нет. +// Бот сам гейтит роли пользователей — сайт доверяет валидному сервис-токену. +const BOT_API_TOKEN = process.env.BOT_API_TOKEN ?? ''; + +// Сравнение постоянного времени (без внешних зависимостей и без throw на разной длине). +function safeEqual(a, b) { + const len = Math.max(a.length, b.length); + let diff = a.length ^ b.length; + for (let i = 0; i < len; i++) { + diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); + } + return diff === 0; +} + +// Требует валидный сервис-токен бота. 503 — если адаптер не настроен, 401 — плохой токен. +export function botAuth(req, res, next) { + if (!BOT_API_TOKEN) { + return res + .status(503) + .json({ error: 'Сайт-адаптер бота не настроен (не задан BOT_API_TOKEN).' }); + } + const header = req.headers.authorization || ''; + const [scheme, value] = header.split(' '); + if (scheme !== 'Bearer' || !value || !safeEqual(value.trim(), BOT_API_TOKEN)) { + return res.status(401).json({ error: 'Недействительный сервис-токен.' }); + } + return next(); +} + +export default botAuth; diff --git a/backend/src/routes/bot.js b/backend/src/routes/bot.js new file mode 100644 index 0000000..77dd2d7 --- /dev/null +++ b/backend/src/routes/bot.js @@ -0,0 +1,227 @@ +// Сайт-адаптер единого бота флота (@servaki_online_bot). +// Реализует контракт из servaki-webhub-bot/SPEC.md («Контракт сайт-адаптера»). +// Все ручки под префиксом /api/bot. Аутентификация — сервис-токен BOT_API_TOKEN +// (Authorization: Bearer ...), кроме /health (публичный пинг живости). +// +// Маппинг на сущности Ники: +// publication ⇄ works (title, slug, description=body, status draft|published|sold) +// material ⇄ media (файл в UPLOAD_DIR, отдаётся nginx из /uploads/) +// Роли пользователей ведёт БОТ; сайт про них не знает и доверяет валидному токену. +import { Router } from 'express'; +import { randomUUID } from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import multer from 'multer'; +import { pool } from '../db/pool.js'; +import { botAuth } from '../middleware/botAuth.js'; + +const router = Router(); + +const SITE_KEY = process.env.BOT_SITE_KEY || 'nika'; +const PUBLIC_BASE = (process.env.SITE_URL || process.env.FRONTEND_URL || '').replace(/\/$/, ''); +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function publicUrl(p) { + return PUBLIC_BASE ? `${PUBLIC_BASE}${p}` : p; +} +function workUrl(slug) { + return publicUrl(`/works/${slug}`); +} + +// ── slug: транслитерация ru→lat + очистка ─────────────────────────── +const TRANSLIT = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', + й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', + у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', + э: 'e', ю: 'yu', я: 'ya', +}; +function slugify(title) { + const base = String(title) + .toLowerCase() + .split('') + .map((ch) => (ch in TRANSLIT ? TRANSLIT[ch] : ch)) + .join('') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return base || 'work'; +} +// Уникальный slug: base, base-2, base-3, … затем короткий случайный суффикс. +async function uniqueSlug(client, base) { + for (let i = 0; i < 6; i++) { + const candidate = i === 0 ? base : `${base}-${i + 1}`; + const { rowCount } = await client.query('SELECT 1 FROM works WHERE slug = $1', [candidate]); + if (rowCount === 0) return candidate; + } + return `${base}-${randomUUID().slice(0, 8)}`; +} + +// ── GET /api/bot/health — публичный пинг ──────────────────────────── +router.get('/health', (_req, res) => { + res.json({ ok: true, site: SITE_KEY }); +}); + +// Всё ниже — только с валидным сервис-токеном бота. +router.use(botAuth); + +// ── GET /api/bot/publications?status=draft|published&limit=20 ─────── +router.get('/publications', async (req, res, next) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); + const status = req.query.status; + const params = [limit]; + let where = ''; + if (status === 'draft' || status === 'published' || status === 'sold') { + params.push(status); + where = 'WHERE status = $2'; + } + const { rows } = await pool.query( + `SELECT id, title, slug, status, created_at + FROM works ${where} + ORDER BY created_at DESC + LIMIT $1`, + params + ); + return res.json( + rows.map((r) => ({ + id: r.id, + title: r.title, + status: r.status, + created_at: r.created_at, + url: workUrl(r.slug), + })) + ); + } catch (err) { + return next(err); + } +}); + +// ── POST /api/bot/publications {title, body, media:[id...]} → черновик ── +router.post('/publications', async (req, res, next) => { + const { title, body, media } = req.body ?? {}; + if (!title || !String(title).trim()) { + return res.status(422).json({ error: 'Поле title обязательно.' }); + } + const mediaIds = Array.isArray(media) ? media.map(String) : []; + const badId = mediaIds.find((id) => !UUID_RE.test(id)); + if (badId) { + return res.status(422).json({ error: `Некорректный id материала: ${badId}` }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const slug = await uniqueSlug(client, slugify(title)); + const ins = await client.query( + `INSERT INTO works (title, slug, description, status) + VALUES ($1, $2, $3, 'draft') + RETURNING id`, + [String(title).trim().slice(0, 300), slug, body ? String(body) : null] + ); + const workId = ins.rows[0].id; + + // Привязываем ранее загруженные материалы (work_id IS NULL) в порядке массива. + for (let i = 0; i < mediaIds.length; i++) { + await client.query( + `UPDATE media SET work_id = $1, sort_order = $2 + WHERE id = $3 AND work_id IS NULL`, + [workId, i, mediaIds[i]] + ); + } + + await client.query('COMMIT'); + return res.status(201).json({ id: workId }); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + return next(err); + } finally { + client.release(); + } +}); + +// ── POST /api/bot/publications/:id/publish → {id, url} ────────────── +router.post('/publications/:id/publish', async (req, res, next) => { + try { + if (!UUID_RE.test(req.params.id)) { + return res.status(404).json({ error: 'Публикация не найдена.' }); + } + const { rows } = await pool.query( + `UPDATE works SET status = 'published', updated_at = now() + WHERE id = $1 + RETURNING id, slug`, + [req.params.id] + ); + if (!rows[0]) return res.status(404).json({ error: 'Публикация не найдена.' }); + return res.json({ id: rows[0].id, url: workUrl(rows[0].slug) }); + } catch (err) { + return next(err); + } +}); + +// ── DELETE /api/bot/publications/:id ──────────────────────────────── +router.delete('/publications/:id', async (req, res, next) => { + try { + if (!UUID_RE.test(req.params.id)) { + return res.status(404).json({ error: 'Публикация не найдена.' }); + } + // media каскадится (ON DELETE CASCADE) — удаляем работу вместе с привязкой. + const { rows } = await pool.query('DELETE FROM works WHERE id = $1 RETURNING id', [ + req.params.id, + ]); + if (!rows[0]) return res.status(404).json({ error: 'Публикация не найдена.' }); + return res.json({ ok: true, id: rows[0].id }); + } catch (err) { + return next(err); + } +}); + +// ── POST /api/bot/materials (multipart file) → {id, url} ──────────── +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, UPLOAD_DIR), + filename: (_req, file, cb) => { + const ext = path + .extname(file.originalname || '') + .toLowerCase() + .replace(/[^.a-z0-9]/g, '') + .slice(0, 12); + cb(null, `${randomUUID()}${ext}`); + }, +}); +const upload = multer({ storage, limits: { fileSize: 32 * 1024 * 1024 } }); + +// Обёртка: аккуратно маппим ошибки multer (размер/формат) в 413/422. +function uploadSingle(req, res, next) { + upload.single('file')(req, res, (err) => { + if (err) { + const code = err.code === 'LIMIT_FILE_SIZE' ? 413 : 422; + return res.status(code).json({ error: err.message }); + } + return next(); + }); +} + +function mediaType(mime) { + if ((mime || '').startsWith('video/')) return 'video'; + if ((mime || '').startsWith('image/')) return 'image'; + return 'file'; +} + +router.post('/materials', uploadSingle, async (req, res, next) => { + try { + if (!req.file) { + return res.status(422).json({ error: 'Файл не передан (multipart-поле "file").' }); + } + const url = publicUrl(`/uploads/${req.file.filename}`); + const { rows } = await pool.query( + `INSERT INTO media (work_id, url, type) VALUES (NULL, $1, $2) RETURNING id`, + [url, mediaType(req.file.mimetype)] + ); + return res.status(201).json({ id: rows[0].id, url }); + } catch (err) { + return next(err); + } +}); + +export default router; 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/bot_adapter.sql b/db/migrations/bot_adapter.sql new file mode 100644 index 0000000..915d161 --- /dev/null +++ b/db/migrations/bot_adapter.sql @@ -0,0 +1,16 @@ +-- nika-gallery: миграция «Сайт-адаптер бота флота». +-- Аддитивно к db/schema.sql и club_access.sql. Идемпотентна. +-- Задача: feat/bot-adapter — ручки /api/bot/* под единый @servaki_online_bot. +-- +-- Единственное изменение схемы: media может хранить произвольные материалы +-- (не только image/video), которые бот присылает через POST /api/bot/materials. +-- Всё остальное (works как «публикации», media как «материалы») уже есть в schema.sql. + +BEGIN; + +-- Разрешаем media.type = 'file' (pdf, документы и пр. от бота). +ALTER TABLE media DROP CONSTRAINT IF EXISTS media_type_check; +ALTER TABLE media ADD CONSTRAINT media_type_check + CHECK (type IN ('image', 'video', 'file')); + +COMMIT; 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; diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..565fda3 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,155 @@ +# Деплой nika-gallery (1nika.ru) + +Полный runbook подъёма сайта Ники на VPS: Docker Compose (backend Express + Next.js +фронт + Postgres 16 + nginx), вход через Яндекс ID и подключение к единому боту флота +(`@servaki_online_bot`) через сайт-адаптер `/api/bot/*`. + +> ⚠️ **Живой VPS-деплой — решение владельца** (выбор ноды и DNS). Этот документ — +> команды и чек-лист; агент репо готовит, но на боевой узел не раскатывает. + +--- + +## 0. Что уже готово в репо +- `docker-compose.yml` — db / backend / frontend / nginx. +- `db/schema.sql` (+ `db/migrations/club_access.sql`, `db/migrations/bot_adapter.sql`). +- Вход через Яндекс ID (`backend/src/routes/oauth.js`), redirect на `1nika.ru`. +- Сайт-адаптер бота (`backend/src/routes/bot.js`, `middleware/botAuth.js`). +- nginx для `1nika.ru` / `www.1nika.ru`. + +--- + +## 1. DNS +На домен **1nika.ru** (регистратор/DNS владельца) добавить A-записи на IP выбранной ноды: + +``` +1nika.ru. A +www.1nika.ru. A +``` + +Дождаться распространения (`dig +short 1nika.ru` должен вернуть ``). + +--- + +## 2. Подготовка сервера +Нужны: Docker + docker compose plugin, открытые порты 80/443. + +```bash +git clone http://192.168.1.120/german/nika-gallery.git /opt/nika-gallery +cd /opt/nika-gallery +git checkout main # либо feat/bot-adapter до мержа +cp .env.example .env +``` + +--- + +## 3. Заполнить `.env` (боевые значения — только на сервере, НЕ в git) + +| Переменная | Значение | Откуда | +|---|---|---| +| `POSTGRES_PASSWORD` | сильный пароль | сгенерировать | +| `DATABASE_URL` | `postgresql://postgres:@db:5432/nika_gallery` | собрать | +| `JWT_SECRET` / `JWT_REFRESH_SECRET` | `openssl rand -hex 32` (каждый свой) | сгенерировать | +| `YANDEX_CLIENT_ID` | `1f64f6ebcd9c45f9af78987204af8e36` | Я.Диск `YANDEX-OAUTH-APPS.secret.txt` [NIKA ART] | +| `YANDEX_CLIENT_SECRET` | `a2b737fc…` | там же | +| `YANDEX_REDIRECT_URI` | `https://1nika.ru/api/auth/yandex/callback` | (уже в .env.example) | +| `FRONTEND_URL` / `SITE_URL` | `https://1nika.ru` | — | +| `BOT_API_TOKEN` | `openssl rand -hex 32` | сгенерировать; **тот же** токен → в реестр бота (`sites.api_token`) + Я.Диск | +| `BOT_SITE_KEY` | `nika` | — | +| `NEXT_PUBLIC_API_URL` | `https://1nika.ru/api` | — | +| `OWNER_EMAIL`, `SMTP_*`, `TELEGRAM_*` | по факту | владелец | + +⚠️ **Секреты не коммитить.** В git — только `.env.example` с пустыми полями. +`YANDEX_REDIRECT_URI` должен **точно** совпадать со значением в кабинете +`oauth.yandex.ru` (приложение NIKA ART) — иначе Яндекс вернёт `redirect_uri_mismatch`. + +--- + +## 4. SSL-сертификат (Let's Encrypt) +nginx ждёт `nginx/certs/fullchain.pem` и `nginx/certs/privkey.pem`. Выпустить certbot'ом +(standalone до старта nginx, либо webroot): + +```bash +# порт 80 должен быть свободен +docker run --rm -p 80:80 \ + -v /opt/nika-gallery/letsencrypt:/etc/letsencrypt \ + certbot/certbot certonly --standalone \ + -d 1nika.ru -d www.1nika.ru --agree-tos -m --no-eff-email + +mkdir -p /opt/nika-gallery/nginx/certs +cp /opt/nika-gallery/letsencrypt/live/1nika.ru/fullchain.pem nginx/certs/ +cp /opt/nika-gallery/letsencrypt/live/1nika.ru/privkey.pem nginx/certs/ +``` + +Продление — cron/systemd-timer с `certbot renew` + копированием в `nginx/certs/` и +`docker compose exec nginx nginx -s reload`. + +--- + +## 5. Старт и миграции +`db/schema.sql` и `db/seed.sql` применяются автоматически при **первой** инициализации +тома Postgres (см. `docker-entrypoint-initdb.d` в compose). Миграции из `db/migrations/` +надо применить вручную (они аддитивны и идемпотентны). + +```bash +docker compose build +docker compose up -d db +# дождаться healthy: +docker compose exec db pg_isready -U postgres + +# Миграции (клуб + Яндекс ID, затем адаптер бота): +docker compose exec -T db psql -U postgres -d nika_gallery < db/migrations/club_access.sql +docker compose exec -T db psql -U postgres -d nika_gallery < db/migrations/bot_adapter.sql + +docker compose up -d # поднять backend + frontend + nginx +``` + +Назначить владельца (Ника) ролью `owner`: +```bash +docker compose exec -T db psql -U postgres -d nika_gallery \ + -c "UPDATE users SET role='owner' WHERE email='';" +``` + +--- + +## 6. Проверка +```bash +curl -s https://1nika.ru/api/health # {status:ok} +curl -s https://1nika.ru/api/bot/health # {ok:true, site:"nika"} +curl -s -o /dev/null -w '%{http_code}\n' \ + https://1nika.ru/api/bot/publications # 401 (без токена) +curl -s -H "Authorization: Bearer $BOT_API_TOKEN" \ + https://1nika.ru/api/bot/publications # [] или список +``` +Вход через Яндекс: открыть `https://1nika.ru/api/auth/yandex` → авторизация → +редирект на `https://1nika.ru/club#token=…`. + +--- + +## 7. Подключение к боту флота +После деплоя владелец в реестре бота (`sites` на finik, +`/var/lib/webhub-bot/state.db`) выставляет для `site_key='nika'`: +- `base_url = https://1nika.ru` +- `api_token = ` +- `status = live` (сейчас `soon`). + +Бот дергает `/api/bot/*` с заголовком `Authorization: Bearer `. Контракт — +`servaki-webhub-bot/SPEC.md`. + +--- + +## 8. Обновления +```bash +cd /opt/nika-gallery && git pull && docker compose build && docker compose up -d +``` +(автоматизировано в `.gitea/workflows/deploy.yml` при push в `main`; требует +секретов раннера `DEPLOY_HOST`, `DEPLOY_KEY`). + +## Приложение: карта ручек адаптера +| Метод | Путь | Auth | Ника | +|---|---|---|---| +| GET | `/api/bot/health` | — | `{ok,site}` | +| GET | `/api/bot/publications?status=&limit=` | Bearer | список works | +| POST | `/api/bot/publications` `{title,body,media[]}` | Bearer | works(draft) → `{id}` | +| POST | `/api/bot/publications/:id/publish` | Bearer | works.status=published → `{id,url}` | +| DELETE | `/api/bot/publications/:id` | Bearer | delete works (+media cascade) | +| POST | `/api/bot/materials` (multipart `file`) | Bearer | media → `{id,url}` | diff --git a/frontend/app/club/gallery/page.tsx b/frontend/app/club/gallery/page.tsx new file mode 100644 index 0000000..f61600a --- /dev/null +++ b/frontend/app/club/gallery/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { authFetch, consumeHashToken, getToken } from '@/lib/auth'; +import type { Work } from '@/lib/types'; + +export default function ClubGalleryPage() { + const [state, setState] = useState<'loading' | 'ok' | 'forbidden' | 'guest'>('loading'); + const [items, setItems] = useState([]); + + useEffect(() => { + consumeHashToken(); + if (!getToken()) { + setState('guest'); + return; + } + authFetch<{ items: Work[] }>('/club/gallery') + .then((r) => { + setItems(r.items); + setState('ok'); + }) + .catch((err) => { + setState((err as { status?: number })?.status === 403 ? 'forbidden' : 'guest'); + }); + }, []); + + return ( +
+
+

Только для членов клуба

+

Закрытая галерея

+
+ + {state === 'loading' && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {(state === 'forbidden' || state === 'guest') && ( +
+

+ {state === 'guest' + ? 'Войдите и получите уровень доступа, чтобы увидеть закрытые работы.' + : 'Этот раздел открыт только членам закрытого клуба.'} +

+ + Перейти в клуб + +
+ )} + + {state === 'ok' && items.length === 0 && ( +

Пока здесь нет работ. Загляните позже.

+ )} + + {state === 'ok' && items.length > 0 && ( +
+ {items.map((w) => ( +
+
+ {w.cover_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {w.title} + ) : null} +
+
+

{w.title}

+ {w.medium &&

{w.medium}

} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/app/club/page.tsx b/frontend/app/club/page.tsx new file mode 100644 index 0000000..2dc757c --- /dev/null +++ b/frontend/app/club/page.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + consumeHashToken, + fetchMe, + isAdmin, + yandexLoginUrl, + type AuthUser, +} from '@/lib/auth'; +import { + clubApi, + type ClubTier, + type ClubTierId, + type MyAccessRequest, + type RequestStatus, +} from '@/lib/club'; +import AdminRequests from '@/components/AdminRequests'; + +// Фолбэк-описание уровней (если API недоступен) — синхронно с backend CLUB_TIERS. +const FALLBACK_TIERS: ClubTier[] = [ + { 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 STATUS_LABEL: Record = { + pending: 'на рассмотрении', + approved: 'одобрена', + declined: 'отклонена', +}; + +function tierTitle(tiers: ClubTier[], id: ClubTierId): string { + return tiers.find((t) => t.id === id)?.title ?? id; +} + +export default function ClubPage() { + const [ready, setReady] = useState(false); + const [user, setUser] = useState(null); + const [tiers, setTiers] = useState(FALLBACK_TIERS); + const [clubTier, setClubTier] = useState(null); + const [myRequest, setMyRequest] = useState(null); + + // Форма заявки. + const [selected, setSelected] = useState(''); + const [note, setNote] = useState(''); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const refreshMine = useCallback(async () => { + try { + const r = await clubApi.myRequest(); + setClubTier(r.club_tier); + setMyRequest(r.request); + } catch { + /* не критично */ + } + }, []); + + useEffect(() => { + consumeHashToken(); + clubApi.tiers().then(setTiers).catch(() => setTiers(FALLBACK_TIERS)); + (async () => { + const me = await fetchMe(); + setUser(me); + if (me) await refreshMine(); + setReady(true); + })(); + }, [refreshMine]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!selected) return; + setBusy(true); + setMsg(null); + try { + await clubApi.submit(selected, note.trim()); + setMsg({ ok: true, text: 'Заявка отправлена. Мы сообщим о решении.' }); + setNote(''); + setSelected(''); + await refreshMine(); + } catch (err) { + const m = (err as { message?: string })?.message || 'Не удалось отправить заявку.'; + setMsg({ ok: false, text: m }); + } finally { + setBusy(false); + } + } + + const canRequest = user && !myRequest && !clubTier; + const pendingSame = myRequest?.status === 'pending'; + + return ( +
+ {/* Заголовок */} +
+

Только по приглашению

+

Закрытый клуб

+

+ Пространство для тех, кто ближе к искусству Ники: закрытые серии body art, + приватные превью, приоритет на аукционах и прямой диалог. Доступ — по заявке + и решению автора. +

+
+ + {/* Уровни доступа */} +
+ {tiers.map((t) => { + const active = clubTier === t.id; + return ( +
+
+

{t.title}

+ {active && ( + + ваш уровень + + )} +
+

{t.desc}

+
+ ); + })} +
+ + {/* Блок действия: вход / заявка / статус */} +
+ {!ready ? ( +
+ ) : !user ? ( +
+

Войдите, чтобы подать заявку

+

+ Быстрый вход через Яндекс ID — без пароля и регистрации. +

+ + + Я + + Войти через Яндекс + +
+ ) : clubTier ? ( +
+

Добро пожаловать

+

+ Вы — {tierTitle(tiers, clubTier)} +

+

+ Закрытая галерея открыта для вас. +

+ + Открыть закрытую галерею + +
+ ) : myRequest && pendingSame ? ( +
+

Заявка на рассмотрении

+

+ Уровень «{tierTitle(tiers, myRequest.tier)}» — статус:{' '} + {STATUS_LABEL[myRequest.status]}. + Мы сообщим о решении. +

+
+ ) : ( +
+

Подать заявку

+ {myRequest?.status === 'declined' && ( +

+ Предыдущая заявка («{tierTitle(tiers, myRequest.tier)}») была отклонена. Вы можете + подать новую. +

+ )} + + + + + +