From 07a0219bcc4ded690d2d19436b90ef9b800284ca Mon Sep 17 00:00:00 2001 From: Crimson Date: Wed, 3 Jun 2026 01:12:20 +0500 Subject: [PATCH] Prepare production deploy for belixa.ru --- .dockerignore | 22 +++ .env.local.example | 2 +- Dockerfile | 13 +- README.md | 184 +++++++++++++++++++++--- docker-compose.yml | 9 +- next-sitemap.config.js | 6 +- public/robots.txt | 4 +- public/sitemap-0.xml | 10 +- public/sitemap.xml | 2 +- src/app/api/admin-login/route.ts | 82 ++++++----- src/app/api/admin/games/upload/route.ts | 159 ++++++++++++-------- src/app/api/uploads/[...path]/route.ts | 94 +++++++----- src/app/layout.tsx | 6 +- src/lib/verifyAdmin.ts | 35 ++--- src/middleware.ts | 59 ++++---- 15 files changed, 467 insertions(+), 220 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fe8788 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.idea + +node_modules +.next +out +build +coverage + +.env +.env.local +.env.* + +data +*.db +*.db-wal +*.db-shm + +npm-debug.log* +yarn-debug.log* +.pnpm-debug.log* +.DS_Store diff --git a/.env.local.example b/.env.local.example index c4b840d..de65aa7 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,5 +1,5 @@ ADMIN_HASHED_PASSWORD="$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK" -JWT_SECRET=!^AqCAhL^XZP$f5cEtKhuo7@!96c%8gChuyJvUH +JWT_SECRET=replace-with-generated-secret TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= diff --git a/Dockerfile b/Dockerfile index 5c5f6a7..80c7db3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ FROM node:22-alpine AS deps WORKDIR /app + +RUN apk add --no-cache python3 make g++ + COPY package.json package-lock.json ./ RUN npm ci FROM node:22-alpine AS builder WORKDIR /app + COPY --from=deps /app/node_modules ./node_modules COPY . . + RUN npm run build FROM node:22-alpine AS runner @@ -19,16 +24,14 @@ ENV HOSTNAME=0.0.0.0 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs -# Standalone-сборка +RUN apk add --no-cache libstdc++ + COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -# SQLite: схема для инициализации БД при первом запуске COPY --from=builder --chown=nextjs:nodejs /app/init ./init -# Директория для SQLite БД и загрузок (монтируется через volume) -RUN mkdir -p /app/data /app/uploads && chown nextjs:nodejs /app/data /app/uploads +RUN mkdir -p /app/data /app/uploads && chown -R nextjs:nodejs /app/data /app/uploads USER nextjs diff --git a/README.md b/README.md index e215bc4..1125b0f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,178 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Strike Arena -## Getting Started +Next.js 15 standalone application for Strike Arena. The app runs on Node.js 22, stores data in SQLite through `better-sqlite3`, and is served by Nginx in Docker Compose. -First, run the development server: +## Runtime Paths + +- SQLite database: `/app/data/strike-arena.db` +- Uploaded files: `/app/uploads` +- Game images in the repository: `uploads/games` + +SQLite WAL and foreign keys are enabled on startup in `src/lib/bd.ts`. + +## Environment + +Create `.env` from the example: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cp .env.local.example .env ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Required variables: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```env +ADMIN_HASHED_PASSWORD= +JWT_SECRET= +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Generate a production `JWT_SECRET`: -## Learn More +```bash +JWT_SECRET_NEW="$(openssl rand -base64 48 | tr -d '\n')" +sed -i "s#^JWT_SECRET=.*#JWT_SECRET=${JWT_SECRET_NEW}#" .env +grep '^JWT_SECRET=' .env +``` -To learn more about Next.js, take a look at the following resources: +Do not deploy with `JWT_SECRET=replace-with-generated-secret`. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Local Development -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm ci +npm run dev +``` -## Deploy on Vercel +Open `http://localhost:3000`. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Production Docker -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +The production Compose file binds Nginx to `10.20.0.25:18083`. + +`uploads` is mounted as `./uploads:/app/uploads` so repository images remain available and new admin uploads are persisted on the host. SQLite data is stored in the Docker volume `sqlite_data`. + +Deploy on `docker-runtime-2`: + +```bash +ssh -i /root/.ssh/docker2_vm_ed25519 crimson@10.20.0.25 +sudo su + +mkdir -p /opt/docker-apps/strike_arena +chown -R crimson:crimson /opt/docker-apps/strike_arena +cd /opt/docker-apps/strike_arena + +git clone https://git.flamy.studio/sbb45/StikeArena_chrt.git . +cp .env.local.example .env +``` + +Set a real `JWT_SECRET`, fill Telegram variables if notifications are needed, then prepare uploads permissions: + +```bash +mkdir -p uploads/games +chown -R 1001:1001 uploads +``` + +Check the port: + +```bash +ss -lntup | grep ':18083' || true +``` + +Build and run: + +```bash +docker compose config +docker compose build --no-cache +docker compose up -d +docker compose ps +docker compose logs -f --tail=120 app nginx +``` + +Runtime checks: + +```bash +curl -I http://10.20.0.25:18083 +curl -I http://10.20.0.25:18083/health +curl -I http://10.20.0.25:18083/api/faq +curl -I http://10.20.0.25:18083/api/games +``` + +SQLite checks: + +```bash +docker compose exec app node - <<'NODE' +const Database = require('better-sqlite3'); +const db = new Database('/app/data/strike-arena.db'); +console.log(db.pragma('journal_mode', { simple: true })); +console.log(db.pragma('foreign_keys', { simple: true })); +db.close(); +NODE +``` + +Expected output: + +```text +wal +1 +``` + +Check persisted paths: + +```bash +docker compose exec app ls -lah /app/data +docker compose exec app ls -lah /app/uploads/games | head +``` + +## Caddy + +Add this on `edge-proxy` after the app responds on `10.20.0.25:18083`: + +```bash +ssh -i /root/.ssh/edge_vm_ed25519 crimson@192.168.0.80 + +curl -I --connect-timeout 5 http://10.20.0.25:18083 +curl -I --connect-timeout 5 http://10.20.0.25:18083/health +sudo grep -R "belixa.ru\|10.20.0.25:18083" -n /etc/caddy || true + +sudo cp -a /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%F-%H%M%S) +``` + +Append the Caddy block: + +```caddyfile +# Strike Arena +http://www.belixa.ru, https://www.belixa.ru { + redir https://belixa.ru{uri} 308 +} + +belixa.ru { + encode zstd gzip + + reverse_proxy 10.20.0.25:18083 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Port 443 + } +} +``` + +Reload Caddy: + +```bash +sudo caddy fmt --overwrite /etc/caddy/Caddyfile +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +sudo systemctl status caddy --no-pager +``` + +Public checks: + +```bash +curl -I https://belixa.ru +curl -I https://www.belixa.ru +curl -I http://belixa.ru +curl -I http://www.belixa.ru +``` diff --git a/docker-compose.yml b/docker-compose.yml index 3f95801..1835bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,12 +12,12 @@ services: expose: - "3000" volumes: - - uploads_data:/app/uploads + - ./uploads:/app/uploads - sqlite_data:/app/data networks: - backend healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/ || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/faq >/dev/null || exit 1"] interval: 30s timeout: 10s retries: 5 @@ -31,20 +31,19 @@ services: app: condition: service_healthy ports: - - "10.20.0.20:18073:80" + - "10.20.0.25:18083:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro networks: - backend healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] interval: 30s timeout: 5s retries: 5 start_period: 10s volumes: - uploads_data: sqlite_data: networks: diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 9c747b6..c578d21 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -1,9 +1,9 @@ /** @type {import('next-sitemap').IConfig} */ module.exports = { - siteUrl: 'https://strike-arena.kz', + siteUrl: 'https://belixa.ru', generateRobotsTxt: true, changefreq: 'weekly', priority: 0.7, sitemapSize: 5000, - exclude: ['/admin', '/private'], -}; + exclude: ['/admin', '/admin/*', '/private', '/private/*'], +}; diff --git a/public/robots.txt b/public/robots.txt index 69428e9..9ab1575 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -3,7 +3,7 @@ User-agent: * Allow: / # Host -Host: https://strike-arena.kz +Host: https://belixa.ru # Sitemaps -Sitemap: https://strike-arena.kz/sitemap.xml +Sitemap: https://belixa.ru/sitemap.xml diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 97a26b3..9b01fd6 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,11 +1,5 @@ -https://strike-arena.kz/admin/contacts2026-06-02T17:07:25.971Zweekly0.7 -https://strike-arena.kz/admin/games2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz/admin/login2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz/admin/hardware2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz/admin/price2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz/admin/faq2026-06-02T17:07:25.972Zweekly0.7 -https://strike-arena.kz/policy2026-06-02T17:07:25.972Zweekly0.7 +https://belixa.ru/policy2026-06-02T20:09:19.888Zweekly0.7 +https://belixa.ru2026-06-02T20:09:19.890Zweekly0.7 \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml index ea875a6..991f225 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,4 +1,4 @@ -https://strike-arena.kz/sitemap-0.xml +https://belixa.ru/sitemap-0.xml \ No newline at end of file diff --git a/src/app/api/admin-login/route.ts b/src/app/api/admin-login/route.ts index 7df3427..55228ed 100644 --- a/src/app/api/admin-login/route.ts +++ b/src/app/api/admin-login/route.ts @@ -1,35 +1,47 @@ -import { NextResponse } from 'next/server'; -import bcrypt from 'bcrypt'; -import jwt from 'jsonwebtoken'; - -const ADMIN_HASHED_PASSWORD = "$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK"; - - -export async function POST(req: Request) { - const { password } = await req.json(); - - try { - const isValid = await bcrypt.compare(password, ADMIN_HASHED_PASSWORD); - - if (!isValid) { - return NextResponse.json({ error: 'Неверный пароль' }, { status: 401 }); - } - - const token = jwt.sign({ role: 'admin' }, process.env.JWT_SECRET!, { expiresIn: "1h" }); - - const res = NextResponse.json({ success: true }); - - res.cookies.set('token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 3600 - }); - - - return res; - } catch (error) { - console.error('Ошибка при выполнении запроса:', error); - return NextResponse.json({ error: 'Произошла ошибка при обработке запроса.' }, { status: 500 }); - } -} +import { NextResponse } from 'next/server'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +function getAuthConfig() { + const adminHash = process.env.ADMIN_HASHED_PASSWORD; + const jwtSecret = process.env.JWT_SECRET; + + if (!adminHash || !jwtSecret || jwtSecret === 'replace-with-generated-secret') { + throw new Error('Admin auth environment is not configured'); + } + + return { adminHash, jwtSecret }; +} + +export async function POST(req: Request) { + try { + const { password } = await req.json(); + + if (typeof password !== 'string') { + return NextResponse.json({ error: 'Некорректный пароль' }, { status: 400 }); + } + + const { adminHash, jwtSecret } = getAuthConfig(); + const isValid = await bcrypt.compare(password, adminHash); + + if (!isValid) { + return NextResponse.json({ error: 'Неверный пароль' }, { status: 401 }); + } + + const token = jwt.sign({ role: 'admin' }, jwtSecret, { expiresIn: '1h' }); + const res = NextResponse.json({ success: true }); + + res.cookies.set('token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 3600, + }); + + return res; + } catch (error) { + console.error('Admin login failed:', error); + return NextResponse.json({ error: 'Произошла ошибка при обработке запроса.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/games/upload/route.ts b/src/app/api/admin/games/upload/route.ts index 608867b..30c6262 100644 --- a/src/app/api/admin/games/upload/route.ts +++ b/src/app/api/admin/games/upload/route.ts @@ -1,56 +1,103 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { verifyAdminToken } from '@/lib/verifyAdmin'; -import { bd } from '@/lib/bd'; -import fs from 'fs/promises'; -import path from 'path'; - -export async function POST(req: NextRequest) { - const token = req.cookies.get('token')?.value; - if (!verifyAdminToken(token)) { - return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 }); - } - - const formData = await req.formData(); - - const title = formData.get('title'); - const platform = formData.get('platform'); - const image = formData.get('image') as File | null; - - if ( - typeof title !== 'string' || - typeof platform !== 'string' || - !image || - typeof image !== 'object' - ) { - return NextResponse.json({ error: 'Заполните все поля корректно' }, { status: 400 }); - } - - const bytes = await image.arrayBuffer(); - const buffer = Buffer.from(bytes); - const fileName = `${Date.now()}-${image.name.replace(/\s+/g, '_')}`; - const uploadDir = path.join(process.cwd(), 'uploads/games'); - const filePath = path.join(uploadDir, fileName); - - try { - // создаём директорию если её нет - await fs.mkdir(uploadDir, { recursive: true }); - - // сохраняем файл - await fs.writeFile(filePath, buffer); - - const [result] = await bd.query( - 'INSERT INTO games (title, image_url, platform) VALUES (?, ?, ?)', - [title, fileName, platform] - ); - - return NextResponse.json({ - id: result.insertId, - title, - platform, - image_url: fileName, - }); - } catch (err) { - console.error('Ошибка при сохранении файла или в базе данных:', err); - return NextResponse.json({ error: 'Ошибка сервера' }, { status: 500 }); - } -} +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdminToken } from '@/lib/verifyAdmin'; +import { bd } from '@/lib/bd'; +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +const MAX_IMAGE_SIZE = 8 * 1024 * 1024; +const ALLOWED_IMAGE_TYPES: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function detectImageExtension(buffer: Buffer, mimeType: string): string | null { + const ext = ALLOWED_IMAGE_TYPES[mimeType]; + if (!ext) return null; + + const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + const isPng = buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const isGif = buffer.length >= 6 && (buffer.subarray(0, 6).toString('ascii') === 'GIF87a' || buffer.subarray(0, 6).toString('ascii') === 'GIF89a'); + const isWebp = buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP'; + + if (ext === 'jpg' && isJpeg) return ext; + if (ext === 'png' && isPng) return ext; + if (ext === 'gif' && isGif) return ext; + if (ext === 'webp' && isWebp) return ext; + + return null; +} + +function getSafeImageName(ext: string): string { + return `${Date.now()}-${crypto.randomUUID()}.${ext}`; +} + +function normalizeText(value: string): string | null { + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + + +export async function POST(req: NextRequest) { + const token = req.cookies.get('token')?.value; + if (!verifyAdminToken(token)) { + return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 }); + } + + const formData = await req.formData(); + const title = formData.get('title'); + const platform = formData.get('platform'); + const image = formData.get('image') as File | null; + + if ( + typeof title !== 'string' || + typeof platform !== 'string' || + !image || + typeof image !== 'object' + ) { + return NextResponse.json({ error: 'Заполните все поля корректно' }, { status: 400 }); + } + + if (image.size <= 0 || image.size > MAX_IMAGE_SIZE) { + return NextResponse.json({ error: 'Загрузите изображение JPG, PNG, WEBP или GIF до 8 МБ' }, { status: 400 }); + } + + const normalizedTitle = normalizeText(title); + const normalizedPlatform = normalizeText(platform); + if (!normalizedTitle || !normalizedPlatform) { + return NextResponse.json({ error: 'Заполните все поля корректно' }, { status: 400 }); + } + + const bytes = await image.arrayBuffer(); + const buffer = Buffer.from(bytes); + const ext = detectImageExtension(buffer, image.type); + if (!ext) { + return NextResponse.json({ error: 'Загрузите корректное изображение JPG, PNG, WEBP или GIF' }, { status: 400 }); + } + + const fileName = getSafeImageName(ext); + const uploadDir = path.join(process.cwd(), 'uploads/games'); + const filePath = path.join(uploadDir, fileName); + + try { + await fs.mkdir(uploadDir, { recursive: true }); + await fs.writeFile(filePath, buffer); + + const [result] = await bd.query( + 'INSERT INTO games (title, image_url, platform) VALUES (?, ?, ?)', + [normalizedTitle, fileName, normalizedPlatform] + ); + + return NextResponse.json({ + id: result.insertId, + title: normalizedTitle, + platform: normalizedPlatform, + image_url: fileName, + }); + } catch (err) { + console.error('Failed to save game image:', err); + return NextResponse.json({ error: 'Ошибка сервера' }, { status: 500 }); + } +} diff --git a/src/app/api/uploads/[...path]/route.ts b/src/app/api/uploads/[...path]/route.ts index 354cbcf..eb4d528 100644 --- a/src/app/api/uploads/[...path]/route.ts +++ b/src/app/api/uploads/[...path]/route.ts @@ -1,34 +1,60 @@ -import { NextRequest, NextResponse } from 'next/server'; -import path from 'path'; -import fs from 'fs/promises'; - -export async function GET(req: NextRequest): Promise { - const pathname = req.nextUrl.pathname; // /api/uploads/games/image.png - const relativePath = pathname.replace(/^\/api\/uploads\//, ''); // games/image.png - - const filePath = path.join(process.cwd(), 'uploads', ...relativePath.split('/')); - - try { - await fs.access(filePath); - const fileBuffer = await fs.readFile(filePath); - - const ext = path.extname(filePath).slice(1).toLowerCase(); - const contentTypes: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - webp: 'image/webp', - gif: 'image/gif', - }; - - return new NextResponse(fileBuffer, { - status: 200, - headers: { - 'Content-Type': contentTypes[ext] || 'application/octet-stream', - }, - }); - } catch (err) { - console.error('Файл не найден:', err); - return new NextResponse('Not found', { status: 404 }); - } -} +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import fs from 'fs/promises'; + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); +const CONTENT_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', +}; + +function resolveUploadPath(relativePath: string): string | null { + const requestedPath = path.normalize(relativePath).replace(/^(\.\.[/\\])+/, ''); + const filePath = path.resolve(UPLOADS_DIR, requestedPath); + const relativeToUploads = path.relative(UPLOADS_DIR, filePath); + + if (relativeToUploads.startsWith('..') || path.isAbsolute(relativeToUploads)) { + return null; + } + + return filePath; +} + +export async function GET(req: NextRequest): Promise { + const pathname = decodeURIComponent(req.nextUrl.pathname); + const relativePath = pathname.replace(/^\/api\/uploads\//, ''); + const filePath = resolveUploadPath(relativePath); + + if (!filePath) { + return new NextResponse('Not found', { status: 404 }); + } + + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return new NextResponse('Not found', { status: 404 }); + } + + const ext = path.extname(filePath).slice(1).toLowerCase(); + const contentType = CONTENT_TYPES[ext]; + if (!contentType) { + return new NextResponse('Not found', { status: 404 }); + } + + const fileBuffer = await fs.readFile(filePath); + + return new NextResponse(new Uint8Array(fileBuffer), { + status: 200, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=2592000, immutable', + }, + }); + } catch (err) { + console.error('Upload file not found:', err); + return new NextResponse('Not found', { status: 404 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 88a4e6b..94495c3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,20 +10,20 @@ export const viewport = { }; export const metadata: Metadata = { - metadataBase: new URL("https://strike-arena.kz"), + metadataBase: new URL("https://belixa.ru"), title: "Strike Arena — Киберклуб нового уровня", description: "Киберклуб Strike Arena: современные ПК и PS5, VIP-кабины, быстрая бронь, турниры и вознаграждения. Бронируй онлайн и выигрывай чаще.", keywords: ["Strike Arena", "киберклуб", "компьютерный клуб", "игровые залы", "бронь ПК", "PlayStation", "турниры", "игры", "e-sports"], applicationName: "Strike Arena", generator: "Next.js", manifest: "/site.webmanifest", - authors: [{ name: "Strike Arena Team", url: "https://strike-arena.kz" }], + authors: [{ name: "Strike Arena Team", url: "https://belixa.ru" }], creator: "Strike Arena", publisher: "Strike Arena", openGraph: { title: "Strike Arena — Игровой клуб нового поколения", description: "Современный киберклуб с бронью мест, турнирами, призами и VIP-комфортом.", - url: "https://strike-arena.kz", + url: "https://belixa.ru", siteName: "Strike Arena", images: [ { diff --git a/src/lib/verifyAdmin.ts b/src/lib/verifyAdmin.ts index 6aeafbc..02a677e 100644 --- a/src/lib/verifyAdmin.ts +++ b/src/lib/verifyAdmin.ts @@ -1,16 +1,19 @@ -import jwt from 'jsonwebtoken' - -interface AdminPayload { - role: string -} - -export function verifyAdminToken(token: string | undefined): boolean { - if (!token) return false - - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AdminPayload - return decoded.role === 'admin' - } catch { - return false - } -} +import jwt from 'jsonwebtoken'; + +interface AdminPayload { + role: string; +} + +export function verifyAdminToken(token: string | undefined): boolean { + if (!token) return false; + + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret || jwtSecret === 'replace-with-generated-secret') return false; + + try { + const decoded = jwt.verify(token, jwtSecret) as AdminPayload; + return decoded.role === 'admin'; + } catch { + return false; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index a400387..a793e42 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,30 +1,29 @@ -import { NextRequest, NextResponse } from "next/server"; -import { jwtVerify } from "jose"; - -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET); - -export async function middleware(req: NextRequest) { - const token = req.cookies.get("token")?.value; - const url = req.nextUrl.clone(); - - if (req.nextUrl.pathname.startsWith("/admin") && req.nextUrl.pathname !== "/admin/login") { - if (!token) { - url.pathname = "/admin/login"; - return NextResponse.redirect(url); - } - - try { - const { payload } = await jwtVerify(token, JWT_SECRET); - if (payload.role !== "admin") throw new Error(); - } catch { - url.pathname = "/admin/login"; - return NextResponse.redirect(url); - } - } - - return NextResponse.next(); -} - -export const config = { - matcher: ["/admin", "/admin/:path*"], -}; +import { NextRequest, NextResponse } from 'next/server'; +import { jwtVerify } from 'jose'; + +export async function middleware(req: NextRequest) { + const token = req.cookies.get('token')?.value; + const url = req.nextUrl.clone(); + const jwtSecret = process.env.JWT_SECRET; + + if (req.nextUrl.pathname.startsWith('/admin') && req.nextUrl.pathname !== '/admin/login') { + if (!token || !jwtSecret || jwtSecret === 'replace-with-generated-secret') { + url.pathname = '/admin/login'; + return NextResponse.redirect(url); + } + + try { + const { payload } = await jwtVerify(token, new TextEncoder().encode(jwtSecret)); + if (payload.role !== 'admin') throw new Error('Invalid role'); + } catch { + url.pathname = '/admin/login'; + return NextResponse.redirect(url); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/admin', '/admin/:path*'], +};