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*'],
+};