From 3f1c5dbd7b32e7701c295159266f7a01c1837aeb Mon Sep 17 00:00:00 2001 From: german Date: Wed, 5 Aug 2026 23:23:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20=D1=81=D0=B0=D0=B9=D1=82-=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BF=D1=82=D0=B5=D1=80=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=B1=D0=BE=D1=82=D0=B0=20=D1=84=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=20+=20=D1=84=D0=B8=D0=BA=D1=81=20redirect=20?= =?UTF-8?q?=D0=BD=D0=B0=201nika.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован контракт /api/bot/* из servaki-webhub-bot/SPEC.md: - GET /api/bot/health (публичный) -> {ok, site} - GET /api/bot/publications?status=&limit= -> список works - POST /api/bot/publications {title,body,media[]} -> черновик works -> {id} - POST /api/bot/publications/:id/publish -> published -> {id,url} - DELETE /api/bot/publications/:id -> удаление (media cascade) - POST /api/bot/materials (multipart file) -> media -> {id,url} Аутентификация Authorization: Bearer (middleware/botAuth, сравнение постоянного времени; 503 без токена в env, 401 при несовпадении). Маппинг: публикация<->works, материал<->media. Миграция bot_adapter.sql разрешает media.type='file'. multer 2.x. Фикс Яндекс-redirect и домена: nika-gallery.ru -> 1nika.ru в .env.example (YANDEX_REDIRECT_URI, FRONTEND_URL, SITE_URL, NEXT_PUBLIC_*, SMTP_USER) и nginx server_name. Секреты не в git (client_id/secret - на Я.Диске). Добавлен docs/DEPLOY.md (VPS runbook: DNS, .env, SSL, миграции, бот). Co-Authored-By: Claude Opus 4.8 --- .env.example | 21 ++- backend/package-lock.json | 101 +++++++++++++ backend/package.json | 3 +- backend/src/index.js | 5 + backend/src/middleware/botAuth.js | 31 ++++ backend/src/routes/bot.js | 227 ++++++++++++++++++++++++++++++ db/migrations/bot_adapter.sql | 16 +++ docs/DEPLOY.md | 155 ++++++++++++++++++++ nginx/nginx.conf | 4 +- 9 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 backend/src/middleware/botAuth.js create mode 100644 backend/src/routes/bot.js create mode 100644 db/migrations/bot_adapter.sql create mode 100644 docs/DEPLOY.md diff --git a/.env.example b/.env.example index 475a0e0..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 @@ -21,18 +21,27 @@ 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://nika-gallery.ru/api/auth/yandex/callback +YANDEX_REDIRECT_URI=https://1nika.ru/api/auth/yandex/callback # Куда вернуть пользователя с токеном (обычно = SITE_URL). Токен приходит в hash: /club#token=... -FRONTEND_URL=https://nika-gallery.ru +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 2ebd92c..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/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" + "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 ce25d02..c710c48 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -12,6 +12,7 @@ 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); @@ -78,6 +79,10 @@ 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/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/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/nginx/nginx.conf b/nginx/nginx.conf index 5b01816..f2f4a2e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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;