feat(bot): сайт-адаптер единого бота флота + фикс redirect на 1nika.ru

Реализован контракт /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 <BOT_API_TOKEN> (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 <noreply@anthropic.com>
This commit is contained in:
german
2026-08-05 23:24:20 +03:00
co-authored by Claude Opus 4.8
parent a355188301
commit 3f1c5dbd7b
9 changed files with 554 additions and 9 deletions
+15 -6
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
@@ -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
+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/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"
}
}
+5
View File
@@ -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: 'Не найдено.' });
+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;
+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;
+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}` |
+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;