Клуб, вход через Яндекс ID, адаптер бота и полировка (#1)
Deploy / deploy (push) Failing after 7s
Deploy / deploy (push) Failing after 7s
Вливает работу от 5-6 августа. main отставал на 4 коммита, а деплой настроен на main — без слияния автодеплой раскатал бы каркас без клуба и авторизации.
This commit was merged in pull request #1.
This commit is contained in:
+22
-4
@@ -9,7 +9,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
# Email
|
# Email
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@nika-gallery.ru
|
SMTP_USER=noreply@1nika.ru
|
||||||
SMTP_PASS=smtp-password
|
SMTP_PASS=smtp-password
|
||||||
OWNER_EMAIL=nika@example.com
|
OWNER_EMAIL=nika@example.com
|
||||||
|
|
||||||
@@ -18,12 +18,30 @@ TELEGRAM_BOT_TOKEN=bot:token
|
|||||||
TELEGRAM_CHANNEL_ID=@nika_channel
|
TELEGRAM_CHANNEL_ID=@nika_channel
|
||||||
OWNER_TELEGRAM_ID=123456789
|
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
|
# App
|
||||||
SITE_URL=https://nika-gallery.ru
|
SITE_URL=https://1nika.ru
|
||||||
PORT=4000
|
PORT=4000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
UPLOAD_DIR=/app/uploads
|
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
|
# Frontend
|
||||||
NEXT_PUBLIC_API_URL=https://nika-gallery.ru/api
|
NEXT_PUBLIC_API_URL=https://1nika.ru/api
|
||||||
NEXT_PUBLIC_WS_URL=wss://nika-gallery.ru/ws
|
NEXT_PUBLIC_WS_URL=wss://1nika.ru/ws
|
||||||
|
|||||||
Generated
+101
@@ -13,6 +13,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.1",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -32,6 +33,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -74,6 +81,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -112,6 +136,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -665,6 +704,25 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -903,6 +961,20 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1082,6 +1154,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -1104,6 +1193,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -1113,6 +1208,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch 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": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.1",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -7,9 +7,12 @@ import cors from 'cors';
|
|||||||
import { pool } from './db/pool.js';
|
import { pool } from './db/pool.js';
|
||||||
import rateLimit from './middleware/rateLimit.js';
|
import rateLimit from './middleware/rateLimit.js';
|
||||||
import authRoutes from './routes/auth.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 worksRoutes from './routes/works.js';
|
||||||
import auctionsRoutes from './routes/auctions.js';
|
import auctionsRoutes from './routes/auctions.js';
|
||||||
import subscriptionsRoutes from './routes/subscriptions.js';
|
import subscriptionsRoutes from './routes/subscriptions.js';
|
||||||
|
import botRoutes from './routes/bot.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT ?? 4000);
|
const PORT = Number(process.env.PORT ?? 4000);
|
||||||
@@ -63,13 +66,23 @@ app.get('/api/health', async (req, res) => {
|
|||||||
return res.json(body);
|
return res.json(body);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate-limit только на аутентификацию (защита от подбора).
|
// Rate-limit только на аутентификацию (защита от подбора). Один раз на префикс —
|
||||||
app.use('/api/auth', rateLimit, authRoutes);
|
// покрывает и локальный вход, и 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/works', worksRoutes);
|
||||||
app.use('/api/auctions', auctionsRoutes);
|
app.use('/api/auctions', auctionsRoutes);
|
||||||
app.use('/api/subscriptions', subscriptionsRoutes);
|
app.use('/api/subscriptions', subscriptionsRoutes);
|
||||||
|
|
||||||
|
// Сайт-адаптер единого бота флота (@servaki_online_bot). Сервис-токен BOT_API_TOKEN.
|
||||||
|
// НЕ под /api/auth rate-limit: /health опрашивается ботом на живость.
|
||||||
|
app.use('/api/bot', botRoutes);
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
res.status(404).json({ error: 'Не найдено.' });
|
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,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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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}` |
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -20,18 +20,27 @@ const body = Inter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL('https://1nika.ru'),
|
||||||
title: {
|
title: {
|
||||||
default: 'Ника — галерея и аукцион',
|
default: 'Ника — галерея и аукцион',
|
||||||
template: '%s · Ника',
|
template: '%s · Ника',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка на новые работы.',
|
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка на новые работы.',
|
||||||
|
alternates: { canonical: '/' },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Ника — галерея и аукцион',
|
title: 'Ника — галерея и аукцион',
|
||||||
description:
|
description:
|
||||||
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
|
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
locale: 'ru_RU',
|
locale: 'ru_RU',
|
||||||
|
url: 'https://1nika.ru',
|
||||||
|
siteName: 'Ника',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Ника — галерея и аукцион',
|
||||||
|
description: 'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Страница не найдена',
|
||||||
|
robots: { index: false, follow: true },
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Галерея' },
|
{ href: '/', label: 'Галерея' },
|
||||||
{ href: '/auctions', label: 'Аукционы' },
|
{ href: '/auctions', label: 'Аукционы' },
|
||||||
|
{ href: '/club', label: 'Клуб' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Nav() {
|
export default function Nav() {
|
||||||
@@ -27,6 +28,14 @@ export default function Nav() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="transition-colors hover:text-ink dark:hover:text-paper"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href="/subscribe"
|
href="/subscribe"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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: '{}' }),
|
||||||
|
};
|
||||||
Generated
+1426
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
|||||||
import type { Config } from 'tailwindcss';
|
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 = {
|
const config: Config = {
|
||||||
content: [
|
content: [
|
||||||
'./app/**/*.{ts,tsx}',
|
'./app/**/*.{ts,tsx}',
|
||||||
@@ -10,26 +15,60 @@ const config: Config = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
// подключается через next/font в layout.tsx как CSS-переменные
|
// Реально загруженные next/font-семейства (layout.tsx):
|
||||||
serif: ['var(--font-display)', 'Georgia', 'serif'],
|
// --font-display = Cormorant Garamond, --font-body = Inter.
|
||||||
sans: ['var(--font-body)', 'system-ui', 'sans-serif'],
|
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: {
|
colors: {
|
||||||
// приглушённая галерейная палитра — тёплый бумажный фон, графитовый текст
|
bg: '#F7F7F5',
|
||||||
|
'bg-alt': '#FAFAFA',
|
||||||
|
ink: '#111111',
|
||||||
|
'ink-soft': '#1b1b1b',
|
||||||
|
gold: '#C8A55A',
|
||||||
|
'gold-deep': '#a9863d',
|
||||||
|
graphite: '#444444',
|
||||||
|
// back-compat: используются текущими компонентами (не ломаем вид).
|
||||||
paper: '#f7f5f1',
|
paper: '#f7f5f1',
|
||||||
ink: '#1a1a1a',
|
accent: '#8a6f52',
|
||||||
muted: '#6b6b6b',
|
muted: '#6b6b6b', // WCAG-AA на белом (≈4.5:1)
|
||||||
line: '#e4e0d8',
|
line: '#e4e0d8',
|
||||||
accent: '#8a6f52', // тёплая бронза для акцентов/цен
|
'line-soft': 'rgba(17,17,17,0.06)',
|
||||||
},
|
},
|
||||||
letterSpacing: {
|
letterSpacing: {
|
||||||
tightest: '-0.03em',
|
tightest: '-0.03em',
|
||||||
|
widener: '0.24em',
|
||||||
},
|
},
|
||||||
maxWidth: {
|
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: {
|
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
@@ -6,13 +6,13 @@ http {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name nika-gallery.ru www.nika-gallery.ru;
|
server_name 1nika.ru www.1nika.ru;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
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 /etc/nginx/certs/fullchain.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||||
|
|||||||
Reference in New Issue
Block a user