Клуб, вход через Яндекс ID, адаптер бота и полировка (#1)
Deploy / deploy (push) Failing after 7s

Вливает работу от 5-6 августа. main отставал на 4 коммита, а деплой настроен на main — без слияния автодеплой раскатал бы каркас без клуба и авторизации.
This commit was merged in pull request #1.
This commit is contained in:
2026-08-06 20:28:01 +00:00
24 changed files with 3340 additions and 18 deletions
+22 -4
View File
@@ -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
+101
View File
@@ -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",
+2 -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/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"
}
}
+15 -2
View File
@@ -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: 'Не найдено.' });
+31
View File
@@ -0,0 +1,31 @@
// Аутентификация единого бота флота (@servaki_online_bot) для ручек /api/bot/*.
// Контракт: Authorization: Bearer <BOT_API_TOKEN>. Токен только из 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;
+227
View File
@@ -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;
+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;
+16
View File
@@ -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;
+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;
+155
View File
@@ -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 <VPS_IP>
www.1nika.ru. A <VPS_IP>
```
Дождаться распространения (`dig +short 1nika.ru` должен вернуть `<VPS_IP>`).
---
## 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:<POSTGRES_PASSWORD>@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 <OWNER_EMAIL> --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='<nika_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 = <BOT_API_TOKEN из .env>`
- `status = live` (сейчас `soon`).
Бот дергает `/api/bot/*` с заголовком `Authorization: Bearer <api_token>`. Контракт —
`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}` |
+90
View File
@@ -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<Work[]>([]);
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 (
<div className="mx-auto max-w-content px-6 py-20 md:px-10">
<header className="mx-auto max-w-2xl text-center">
<p className="text-sm uppercase tracking-[0.25em] text-accent">Только для членов клуба</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest">Закрытая галерея</h1>
</header>
{state === 'loading' && (
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-[4/5] animate-shimmer rounded-2xl border border-line dark:border-white/10" />
))}
</div>
)}
{(state === 'forbidden' || state === 'guest') && (
<div className="mx-auto mt-16 max-w-md rounded-3xl border border-line p-8 text-center dark:border-white/10">
<p className="text-muted">
{state === 'guest'
? 'Войдите и получите уровень доступа, чтобы увидеть закрытые работы.'
: 'Этот раздел открыт только членам закрытого клуба.'}
</p>
<Link
href="/club"
className="mt-6 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Перейти в клуб
</Link>
</div>
)}
{state === 'ok' && items.length === 0 && (
<p className="mt-16 text-center text-muted">Пока здесь нет работ. Загляните позже.</p>
)}
{state === 'ok' && items.length > 0 && (
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((w) => (
<article
key={w.id}
className="group overflow-hidden rounded-2xl border border-line dark:border-white/10"
>
<div className="aspect-[4/5] overflow-hidden bg-line/40">
{w.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={w.cover_thumb || w.cover_url}
alt={w.title}
className="h-full w-full object-cover transition-transform duration-700 ease-gallery group-hover:scale-105"
/>
) : null}
</div>
<div className="p-4">
<h3 className="font-serif text-xl tracking-tightest">{w.title}</h3>
{w.medium && <p className="mt-1 text-sm text-muted">{w.medium}</p>}
</div>
</article>
))}
</div>
)}
</div>
);
}
+242
View File
@@ -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<RequestStatus, string> = {
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<AuthUser | null>(null);
const [tiers, setTiers] = useState<ClubTier[]>(FALLBACK_TIERS);
const [clubTier, setClubTier] = useState<ClubTierId | null>(null);
const [myRequest, setMyRequest] = useState<MyAccessRequest | null>(null);
// Форма заявки.
const [selected, setSelected] = useState<ClubTierId | ''>('');
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 (
<div className="mx-auto max-w-content px-6 py-20 md:px-10">
{/* Заголовок */}
<header className="mx-auto max-w-2xl text-center">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Только по приглашению</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest md:text-6xl">Закрытый клуб</h1>
<p className="mt-6 leading-relaxed text-muted">
Пространство для тех, кто ближе к искусству Ники: закрытые серии body art,
приватные превью, приоритет на аукционах и прямой диалог. Доступ — по заявке
и решению автора.
</p>
</header>
{/* Уровни доступа */}
<section className="mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{tiers.map((t) => {
const active = clubTier === t.id;
return (
<article
key={t.id}
className={`group relative rounded-3xl border p-7 transition-colors ${
active
? 'border-accent bg-accent/5'
: 'border-line hover:border-ink/30 dark:border-white/10'
}`}
>
<div className="flex items-center justify-between">
<h3 className="font-serif text-2xl tracking-tightest">{t.title}</h3>
{active && (
<span className="rounded-full bg-accent px-3 py-1 text-xs text-paper">
ваш уровень
</span>
)}
</div>
<p className="mt-3 text-sm leading-relaxed text-muted">{t.desc}</p>
</article>
);
})}
</section>
{/* Блок действия: вход / заявка / статус */}
<section className="mx-auto mt-16 max-w-xl">
{!ready ? (
<div className="h-40 animate-shimmer rounded-3xl border border-line dark:border-white/10" />
) : !user ? (
<div className="rounded-3xl border border-line p-8 text-center dark:border-white/10">
<h2 className="font-serif text-2xl tracking-tightest">Войдите, чтобы подать заявку</h2>
<p className="mt-3 text-sm text-muted">
Быстрый вход через Яндекс ID — без пароля и регистрации.
</p>
<a
href={yandexLoginUrl()}
className="mt-6 inline-flex items-center justify-center gap-3 rounded-full border border-ink/15 bg-white px-6 py-3.5 text-sm font-medium text-ink shadow-sm transition-colors hover:border-ink/40 dark:bg-white/5 dark:text-paper"
>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FC3F1D] text-[13px] font-bold leading-none text-white">
Я
</span>
Войти через Яндекс
</a>
</div>
) : clubTier ? (
<div className="rounded-3xl border border-accent bg-accent/5 p-8 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-accent">Добро пожаловать</p>
<h2 className="mt-3 font-serif text-3xl tracking-tightest">
Вы — {tierTitle(tiers, clubTier)}
</h2>
<p className="mt-3 text-sm text-muted">
Закрытая галерея открыта для вас.
</p>
<a
href="/club/gallery"
className="mt-6 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Открыть закрытую галерею
</a>
</div>
) : myRequest && pendingSame ? (
<div className="rounded-3xl border border-line p-8 text-center dark:border-white/10">
<h2 className="font-serif text-2xl tracking-tightest">Заявка на рассмотрении</h2>
<p className="mt-3 text-sm text-muted">
Уровень «{tierTitle(tiers, myRequest.tier)}» — статус:{' '}
<span className="text-ink dark:text-paper">{STATUS_LABEL[myRequest.status]}</span>.
Мы сообщим о решении.
</p>
</div>
) : (
<form
onSubmit={onSubmit}
className="rounded-3xl border border-line p-8 dark:border-white/10"
>
<h2 className="font-serif text-2xl tracking-tightest">Подать заявку</h2>
{myRequest?.status === 'declined' && (
<p className="mt-2 text-sm text-muted">
Предыдущая заявка («{tierTitle(tiers, myRequest.tier)}») была отклонена. Вы можете
подать новую.
</p>
)}
<label className="mt-6 block text-sm text-muted">Уровень</label>
<select
required
value={selected}
onChange={(e) => setSelected(e.target.value as ClubTierId)}
className="mt-2 w-full rounded-2xl border border-line bg-transparent px-4 py-3 text-sm outline-none focus:border-ink dark:border-white/15 dark:bg-transparent"
>
<option value="" disabled>
Выберите уровень…
</option>
{tiers.map((t) => (
<option key={t.id} value={t.id} className="text-ink">
{t.title}
</option>
))}
</select>
<label className="mt-5 block text-sm text-muted">О себе (необязательно)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={4}
maxLength={2000}
placeholder="Пара слов о себе и почему вам интересен клуб…"
className="mt-2 w-full resize-none rounded-2xl border border-line bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
{msg && (
<p className={`mt-4 text-sm ${msg.ok ? 'text-accent' : 'text-[#b23b3b]'}`}>
{msg.text}
</p>
)}
<button
type="submit"
disabled={busy || !canRequest}
className="mt-6 w-full rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent disabled:opacity-60"
>
{busy ? 'Отправляем…' : 'Отправить заявку'}
</button>
</form>
)}
</section>
{/* Админ-панель модерации */}
{isAdmin(user) && <AdminRequests tiers={tiers} />}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+9
View File
@@ -20,18 +20,27 @@ const body = Inter({
});
export const metadata: Metadata = {
metadataBase: new URL('https://1nika.ru'),
title: {
default: 'Ника — галерея и аукцион',
template: '%s · Ника',
},
description:
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка на новые работы.',
alternates: { canonical: '/' },
openGraph: {
title: 'Ника — галерея и аукцион',
description:
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
type: 'website',
locale: 'ru_RU',
url: 'https://1nika.ru',
siteName: 'Ника',
},
twitter: {
card: 'summary_large_image',
title: 'Ника — галерея и аукцион',
description: 'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
},
};
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/api';
import {
consumeHashToken,
fetchMe,
getToken,
setTokens,
yandexLoginUrl,
type AuthUser,
} from '@/lib/auth';
// Иконка Яндекса (моно, вписана в кнопку).
function YandexMark() {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FC3F1D] text-[13px] font-bold leading-none text-white">
Я
</span>
);
}
export default function LoginPage() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// На случай, если Яндекс вернул токен именно на /login.
consumeHashToken();
if (getToken()) fetchMe().then(setUser);
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || 'Не удалось войти.');
setTokens(data.access_token, data.refresh_token);
router.push('/club');
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа.');
} finally {
setBusy(false);
}
}
if (user) {
return (
<section className="mx-auto max-w-md px-6 py-24 text-center md:px-10">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Вход</p>
<h1 className="mt-4 font-serif text-4xl tracking-tightest">
Вы вошли как {user.name}
</h1>
<Link
href="/club"
className="mt-8 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Перейти в закрытый клуб
</Link>
</section>
);
}
return (
<section className="mx-auto max-w-md px-6 py-24 md:px-10">
<p className="text-center text-sm uppercase tracking-[0.25em] text-muted">Личный кабинет</p>
<h1 className="mt-4 text-center font-serif text-4xl tracking-tightest">Вход</h1>
<a
href={yandexLoginUrl()}
className="mt-10 flex w-full items-center justify-center gap-3 rounded-full border border-ink/15 bg-white px-6 py-3.5 text-sm font-medium text-ink shadow-sm transition-colors hover:border-ink/40 dark:bg-white/5 dark:text-paper"
>
<YandexMark />
Войти через Яндекс
</a>
<div className="my-8 flex items-center gap-4 text-xs uppercase tracking-widest text-muted">
<span className="h-px flex-1 bg-line" />
или по e-mail
<span className="h-px flex-1 bg-line" />
</div>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<input
type="email"
required
placeholder="e-mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-2xl border border-line bg-transparent px-5 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
<input
type="password"
required
placeholder="пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-2xl border border-line bg-transparent px-5 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
{error && <p className="text-sm text-[#b23b3b]">{error}</p>}
<button
type="submit"
disabled={busy}
className="mt-2 rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent disabled:opacity-60"
>
{busy ? 'Входим…' : 'Войти'}
</button>
</form>
<p className="mt-8 text-center text-sm text-muted">
Закрытая платформа. Регистрация — по приглашению или через{' '}
<Link href="/club" className="text-accent underline-offset-4 hover:underline">
заявку в клуб
</Link>
.
</p>
</section>
);
}
+6
View File
@@ -1,4 +1,10 @@
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Страница не найдена',
robots: { index: false, follow: true },
};
export default function NotFound() {
return (
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
clubApi,
type AdminAccessRequest,
type ClubTier,
type ClubTierId,
} from '@/lib/club';
const STATUS_TABS: { id: string; label: string }[] = [
{ id: 'pending', label: 'Новые' },
{ id: 'approved', label: 'Одобренные' },
{ id: 'declined', label: 'Отклонённые' },
];
function tierTitle(tiers: ClubTier[], id: ClubTierId): string {
return tiers.find((t) => t.id === id)?.title ?? id;
}
// Мини-панель модерации заявок (видна только админам).
export default function AdminRequests({ tiers }: { tiers: ClubTier[] }) {
const [tab, setTab] = useState('pending');
const [items, setItems] = useState<AdminAccessRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actingId, setActingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (status: string) => {
setLoading(true);
setError(null);
try {
setItems(await clubApi.listRequests(status));
} catch (err) {
setError((err as { message?: string })?.message || 'Не удалось загрузить заявки.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(tab);
}, [tab, load]);
async function act(id: string, action: 'approve' | 'decline') {
setActingId(id);
setError(null);
try {
if (action === 'approve') await clubApi.approve(id);
else await clubApi.decline(id);
// Убираем из текущего списка (pending → пусто), либо перезагружаем.
setItems((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
setError((err as { message?: string })?.message || 'Ошибка. Повторите.');
} finally {
setActingId(null);
}
}
return (
<section className="mx-auto mt-24 max-w-3xl">
<div className="flex items-center justify-between border-b border-line pb-4 dark:border-white/10">
<h2 className="font-serif text-3xl tracking-tightest">Модерация заявок</h2>
<div className="flex gap-1 rounded-full border border-line p-1 text-xs dark:border-white/10">
{STATUS_TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`rounded-full px-3 py-1.5 transition-colors ${
tab === t.id ? 'bg-ink text-paper' : 'text-muted hover:text-ink dark:hover:text-paper'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{error && <p className="mt-4 text-sm text-[#b23b3b]">{error}</p>}
{loading ? (
<div className="mt-6 h-24 animate-shimmer rounded-2xl border border-line dark:border-white/10" />
) : items.length === 0 ? (
<p className="mt-10 text-center text-sm text-muted">Заявок нет.</p>
) : (
<ul className="mt-6 space-y-4">
{items.map((r) => (
<li
key={r.id}
className="flex flex-col gap-4 rounded-2xl border border-line p-5 sm:flex-row sm:items-start sm:justify-between dark:border-white/10"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink dark:text-paper">{r.user_name}</span>
<span className="rounded-full bg-accent/10 px-2.5 py-0.5 text-xs text-accent">
{tierTitle(tiers, r.tier)}
</span>
</div>
<p className="mt-0.5 text-xs text-muted">{r.user_email}</p>
{r.note && (
<p className="mt-2 max-w-prose text-sm leading-relaxed text-muted">«{r.note}»</p>
)}
<p className="mt-2 text-xs text-muted">
{new Date(r.created_at).toLocaleString('ru-RU')}
{r.user_current_tier && ` · текущий уровень: ${tierTitle(tiers, r.user_current_tier)}`}
</p>
</div>
{r.status === 'pending' && (
<div className="flex shrink-0 gap-2">
<button
disabled={actingId === r.id}
onClick={() => act(r.id, 'approve')}
className="rounded-full bg-ink px-4 py-2 text-xs text-paper transition-colors hover:bg-accent disabled:opacity-60"
>
Одобрить
</button>
<button
disabled={actingId === r.id}
onClick={() => act(r.id, 'decline')}
className="rounded-full border border-line px-4 py-2 text-xs text-muted transition-colors hover:border-ink hover:text-ink disabled:opacity-60 dark:border-white/15 dark:hover:text-paper"
>
Отклонить
</button>
</div>
)}
{r.status !== 'pending' && (
<span className="shrink-0 self-start rounded-full border border-line px-3 py-1 text-xs text-muted dark:border-white/10">
{r.status === 'approved' ? 'одобрена' : 'отклонена'}
</span>
)}
</li>
))}
</ul>
)}
</section>
);
}
+9
View File
@@ -3,6 +3,7 @@ import Link from 'next/link';
const links = [
{ href: '/', label: 'Галерея' },
{ href: '/auctions', label: 'Аукционы' },
{ href: '/club', label: 'Клуб' },
];
export default function Nav() {
@@ -27,6 +28,14 @@ export default function Nav() {
</Link>
</li>
))}
<li>
<Link
href="/login"
className="transition-colors hover:text-ink dark:hover:text-paper"
>
Войти
</Link>
</li>
<li>
<Link
href="/subscribe"
+100
View File
@@ -0,0 +1,100 @@
// Клиентский помощник авторизации (браузер).
// Токен храним в localStorage; редирект Яндекса приносит его в hash: /club#token=...&refresh=...
'use client';
import { API_BASE } from './api';
const TOKEN_KEY = 'nika_token';
const REFRESH_KEY = 'nika_refresh';
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'owner' | 'admin' | 'buyer' | 'subscriber';
telegram_id?: number | null;
club_tier?: string | null;
avatar_url?: string | null;
created_at?: string;
}
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(TOKEN_KEY);
}
export function setTokens(token: string, refresh?: string | null) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(TOKEN_KEY, token);
if (refresh) window.localStorage.setItem(REFRESH_KEY, refresh);
}
export function clearTokens() {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(REFRESH_KEY);
}
// Разбирает токен из hash после редиректа OAuth. Возвращает true, если токен найден и сохранён.
export function consumeHashToken(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash.replace(/^#/, '');
if (!hash) return false;
const params = new URLSearchParams(hash);
const token = params.get('token');
if (!token) return false;
setTokens(token, params.get('refresh'));
// Чистим hash, чтобы токен не «висел» в адресной строке.
history.replaceState(null, '', window.location.pathname + window.location.search);
return true;
}
// Авторизованный fetch к нашему API. Кидает объект { status, message } при ошибке.
export async function authFetch<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const token = getToken();
const res = await fetch(`${API_BASE}${path.startsWith('/') ? path : `/${path}`}`, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers || {}),
},
});
let data: unknown = null;
try {
data = await res.json();
} catch {
/* пустое тело */
}
if (!res.ok) {
const message =
(data && typeof data === 'object' && 'error' in data && (data as { error: string }).error) ||
`${res.status} ${res.statusText}`;
throw { status: res.status, message } as { status: number; message: string };
}
return data as T;
}
// Текущий пользователь (или null, если не авторизован / токен протух).
export async function fetchMe(): Promise<AuthUser | null> {
if (!getToken()) return null;
try {
const { user } = await authFetch<{ user: AuthUser }>('/auth/me');
return user;
} catch {
return null;
}
}
// Ссылка на старт входа через Яндекс (полный путь до backend).
export function yandexLoginUrl(): string {
return `${API_BASE}/auth/yandex`;
}
export function isAdmin(user: AuthUser | null): boolean {
return !!user && (user.role === 'admin' || user.role === 'owner');
}
+64
View File
@@ -0,0 +1,64 @@
// Типы и API-обёртки закрытого клуба (клиентская сторона).
'use client';
import { authFetch } from './auth';
export type ClubTierId =
| 'vip'
| 'collector'
| 'partner'
| 'photographer'
| 'organizer'
| 'press';
export interface ClubTier {
id: ClubTierId;
title: string;
desc: string;
}
export type RequestStatus = 'pending' | 'approved' | 'declined';
export interface MyAccessRequest {
id: string;
tier: ClubTierId;
status: RequestStatus;
note: string | null;
admin_note?: string | null;
created_at: string;
reviewed_at?: string | null;
}
export interface AdminAccessRequest extends MyAccessRequest {
user_id: string;
user_name: string;
user_email: string;
user_avatar: string | null;
user_current_tier: ClubTierId | null;
}
export const clubApi = {
tiers: () => authFetch<{ tiers: ClubTier[] }>('/club/tiers').then((r) => r.tiers),
myRequest: () =>
authFetch<{ club_tier: ClubTierId | null; request: MyAccessRequest | null }>(
'/club/my-request',
),
submit: (tier: ClubTierId, note: string) =>
authFetch<{ request: MyAccessRequest }>('/club/request', {
method: 'POST',
body: JSON.stringify({ tier, note }),
}),
listRequests: (status = 'pending') =>
authFetch<{ items: AdminAccessRequest[] }>(
`/club/requests?status=${encodeURIComponent(status)}`,
).then((r) => r.items),
approve: (id: string) =>
authFetch(`/club/requests/${id}/approve`, { method: 'POST', body: '{}' }),
decline: (id: string) =>
authFetch(`/club/requests/${id}/decline`, { method: 'POST', body: '{}' }),
};
+1426
View File
File diff suppressed because it is too large Load Diff
+48 -9
View File
@@ -1,5 +1,10 @@
import type { Config } from 'tailwindcss';
// NIKA ART — премиум design-system.
// Палитра: bg #F7F7F5 / #FAFAFA · ink #111 · gold #C8A55A · graphite #444.
// Токены paper/accent/muted оставлены (back-compat) — их используют текущие
// компоненты; новые gold/bg/graphite аддитивны. Шрифты грузятся в layout.tsx
// как next/font: --font-display = Cormorant Garamond, --font-body = Inter.
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
@@ -10,26 +15,60 @@ const config: Config = {
theme: {
extend: {
fontFamily: {
// подключается через next/font в layout.tsx как CSS-переменные
serif: ['var(--font-display)', 'Georgia', 'serif'],
sans: ['var(--font-body)', 'system-ui', 'sans-serif'],
// Реально загруженные next/font-семейства (layout.tsx):
// --font-display = Cormorant Garamond, --font-body = Inter.
display: ['var(--font-display)', 'Cormorant Garamond', 'Georgia', 'serif'],
serif: ['var(--font-display)', 'Cormorant Garamond', 'Georgia', 'serif'],
sans: ['var(--font-body)', 'Inter', 'system-ui', 'sans-serif'],
},
colors: {
// приглушённая галерейная палитра — тёплый бумажный фон, графитовый текст
bg: '#F7F7F5',
'bg-alt': '#FAFAFA',
ink: '#111111',
'ink-soft': '#1b1b1b',
gold: '#C8A55A',
'gold-deep': '#a9863d',
graphite: '#444444',
// back-compat: используются текущими компонентами (не ломаем вид).
paper: '#f7f5f1',
ink: '#1a1a1a',
muted: '#6b6b6b',
accent: '#8a6f52',
muted: '#6b6b6b', // WCAG-AA на белом (≈4.5:1)
line: '#e4e0d8',
accent: '#8a6f52', // тёплая бронза для акцентов/цен
'line-soft': 'rgba(17,17,17,0.06)',
},
letterSpacing: {
tightest: '-0.03em',
widener: '0.24em',
},
maxWidth: {
content: '1280px',
content: '1320px',
},
borderRadius: {
's': '10px',
'm': '18px',
'l': '28px',
},
boxShadow: {
's': '0 2px 10px rgba(17,17,17,.05)',
'm': '0 18px 50px -20px rgba(17,17,17,.22)',
'l': '0 40px 90px -30px rgba(17,17,17,.35)',
gold: '0 14px 30px -14px rgba(169,134,61,.7)',
},
transitionTimingFunction: {
gallery: 'cubic-bezier(0.22, 1, 0.36, 1)',
gallery: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
keyframes: {
slide: { to: { transform: 'translateX(-50%)' } },
cue: {
'0%': { transform: 'scaleY(0)', transformOrigin: 'top' },
'45%': { transform: 'scaleY(1)', transformOrigin: 'top' },
'55%': { transform: 'scaleY(1)', transformOrigin: 'bottom' },
'100%': { transform: 'scaleY(0)', transformOrigin: 'bottom' },
},
},
animation: {
marquee: 'slide 34s linear infinite',
cue: 'cue 2.2s cubic-bezier(0.16,1,0.3,1) infinite',
},
},
},
+2 -2
View File
@@ -6,13 +6,13 @@ http {
server {
listen 80;
server_name nika-gallery.ru www.nika-gallery.ru;
server_name 1nika.ru www.1nika.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name nika-gallery.ru www.nika-gallery.ru;
server_name 1nika.ru www.1nika.ru;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;