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:
co-authored by
Claude Opus 4.8
parent
a355188301
commit
3f1c5dbd7b
+15
-6
@@ -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
|
||||
|
||||
Generated
+101
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'Не найдено.' });
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user