Prepare production deploy for belixa.ru
This commit is contained in:
@@ -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
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
ADMIN_HASHED_PASSWORD="$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK"
|
ADMIN_HASHED_PASSWORD="$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK"
|
||||||
JWT_SECRET=!^AqCAhL^XZP$f5cEtKhuo7@!96c%8gChuyJvUH
|
JWT_SECRET=replace-with-generated-secret
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|||||||
+8
-5
@@ -1,12 +1,17 @@
|
|||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
@@ -19,16 +24,14 @@ ENV HOSTNAME=0.0.0.0
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Standalone-сборка
|
RUN apk add --no-cache libstdc++
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# SQLite: схема для инициализации БД при первом запуске
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/init ./init
|
COPY --from=builder --chown=nextjs:nodejs /app/init ./init
|
||||||
|
|
||||||
# Директория для SQLite БД и загрузок (монтируется через volume)
|
RUN mkdir -p /app/data /app/uploads && chown -R nextjs:nodejs /app/data /app/uploads
|
||||||
RUN mkdir -p /app/data /app/uploads && chown nextjs:nodejs /app/data /app/uploads
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
```bash
|
||||||
npm run dev
|
cp .env.local.example .env
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## Local Development
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|||||||
+4
-5
@@ -12,12 +12,12 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -31,20 +31,19 @@ services:
|
|||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "10.20.0.20:18073:80"
|
- "10.20.0.25:18083:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
uploads_data:
|
|
||||||
sqlite_data:
|
sqlite_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: 'https://strike-arena.kz',
|
siteUrl: 'https://belixa.ru',
|
||||||
generateRobotsTxt: true,
|
generateRobotsTxt: true,
|
||||||
changefreq: 'weekly',
|
changefreq: 'weekly',
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
sitemapSize: 5000,
|
sitemapSize: 5000,
|
||||||
exclude: ['/admin', '/private'],
|
exclude: ['/admin', '/admin/*', '/private', '/private/*'],
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ User-agent: *
|
|||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
# Host
|
# Host
|
||||||
Host: https://strike-arena.kz
|
Host: https://belixa.ru
|
||||||
|
|
||||||
# Sitemaps
|
# Sitemaps
|
||||||
Sitemap: https://strike-arena.kz/sitemap.xml
|
Sitemap: https://belixa.ru/sitemap.xml
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||||
<url><loc>https://strike-arena.kz/admin/contacts</loc><lastmod>2026-06-02T17:07:25.971Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
<url><loc>https://belixa.ru/policy</loc><lastmod>2026-06-02T20:09:19.888Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://strike-arena.kz/admin/games</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
<url><loc>https://belixa.ru</loc><lastmod>2026-06-02T20:09:19.890Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://strike-arena.kz/admin/login</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
<url><loc>https://strike-arena.kz/admin/hardware</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
<url><loc>https://strike-arena.kz/admin/price</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
<url><loc>https://strike-arena.kz</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
<url><loc>https://strike-arena.kz/admin/faq</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
<url><loc>https://strike-arena.kz/policy</loc><lastmod>2026-06-02T17:07:25.972Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
|
||||||
</urlset>
|
</urlset>
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<sitemap><loc>https://strike-arena.kz/sitemap-0.xml</loc></sitemap>
|
<sitemap><loc>https://belixa.ru/sitemap-0.xml</loc></sitemap>
|
||||||
</sitemapindex>
|
</sitemapindex>
|
||||||
@@ -2,34 +2,46 @@ import { NextResponse } from 'next/server';
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
const ADMIN_HASHED_PASSWORD = "$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK";
|
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) {
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
const { password } = await req.json();
|
const { password } = await req.json();
|
||||||
|
|
||||||
try {
|
if (typeof password !== 'string') {
|
||||||
const isValid = await bcrypt.compare(password, ADMIN_HASHED_PASSWORD);
|
return NextResponse.json({ error: 'Некорректный пароль' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { adminHash, jwtSecret } = getAuthConfig();
|
||||||
|
const isValid = await bcrypt.compare(password, adminHash);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return NextResponse.json({ error: 'Неверный пароль' }, { status: 401 });
|
return NextResponse.json({ error: 'Неверный пароль' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign({ role: 'admin' }, process.env.JWT_SECRET!, { expiresIn: "1h" });
|
const token = jwt.sign({ role: 'admin' }, jwtSecret, { expiresIn: '1h' });
|
||||||
|
|
||||||
const res = NextResponse.json({ success: true });
|
const res = NextResponse.json({ success: true });
|
||||||
|
|
||||||
res.cookies.set('token', token, {
|
res.cookies.set('token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 3600
|
maxAge: 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при выполнении запроса:', error);
|
console.error('Admin login failed:', error);
|
||||||
return NextResponse.json({ error: 'Произошла ошибка при обработке запроса.' }, { status: 500 });
|
return NextResponse.json({ error: 'Произошла ошибка при обработке запроса.' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,42 @@ import { verifyAdminToken } from '@/lib/verifyAdmin';
|
|||||||
import { bd } from '@/lib/bd';
|
import { bd } from '@/lib/bd';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE = 8 * 1024 * 1024;
|
||||||
|
const ALLOWED_IMAGE_TYPES: Record<string, string> = {
|
||||||
|
'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) {
|
export async function POST(req: NextRequest) {
|
||||||
const token = req.cookies.get('token')?.value;
|
const token = req.cookies.get('token')?.value;
|
||||||
@@ -11,7 +47,6 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
|
|
||||||
const title = formData.get('title');
|
const title = formData.get('title');
|
||||||
const platform = formData.get('platform');
|
const platform = formData.get('platform');
|
||||||
const image = formData.get('image') as File | null;
|
const image = formData.get('image') as File | null;
|
||||||
@@ -25,32 +60,44 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Заполните все поля корректно' }, { status: 400 });
|
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 bytes = await image.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
const fileName = `${Date.now()}-${image.name.replace(/\s+/g, '_')}`;
|
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 uploadDir = path.join(process.cwd(), 'uploads/games');
|
||||||
const filePath = path.join(uploadDir, fileName);
|
const filePath = path.join(uploadDir, fileName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// создаём директорию если её нет
|
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
// сохраняем файл
|
|
||||||
await fs.writeFile(filePath, buffer);
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
const [result] = await bd.query(
|
const [result] = await bd.query(
|
||||||
'INSERT INTO games (title, image_url, platform) VALUES (?, ?, ?)',
|
'INSERT INTO games (title, image_url, platform) VALUES (?, ?, ?)',
|
||||||
[title, fileName, platform]
|
[normalizedTitle, fileName, normalizedPlatform]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: result.insertId,
|
id: result.insertId,
|
||||||
title,
|
title: normalizedTitle,
|
||||||
platform,
|
platform: normalizedPlatform,
|
||||||
image_url: fileName,
|
image_url: fileName,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при сохранении файла или в базе данных:', err);
|
console.error('Failed to save game image:', err);
|
||||||
return NextResponse.json({ error: 'Ошибка сервера' }, { status: 500 });
|
return NextResponse.json({ error: 'Ошибка сервера' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,59 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
||||||
const pathname = req.nextUrl.pathname; // /api/uploads/games/image.png
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
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<string, string> = {
|
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
};
|
};
|
||||||
|
|
||||||
return new NextResponse(fileBuffer, {
|
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<NextResponse> {
|
||||||
|
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,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentTypes[ext] || 'application/octet-stream',
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=2592000, immutable',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Файл не найден:', err);
|
console.error('Upload file not found:', err);
|
||||||
return new NextResponse('Not found', { status: 404 });
|
return new NextResponse('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -10,20 +10,20 @@ export const viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://strike-arena.kz"),
|
metadataBase: new URL("https://belixa.ru"),
|
||||||
title: "Strike Arena — Киберклуб нового уровня",
|
title: "Strike Arena — Киберклуб нового уровня",
|
||||||
description: "Киберклуб Strike Arena: современные ПК и PS5, VIP-кабины, быстрая бронь, турниры и вознаграждения. Бронируй онлайн и выигрывай чаще.",
|
description: "Киберклуб Strike Arena: современные ПК и PS5, VIP-кабины, быстрая бронь, турниры и вознаграждения. Бронируй онлайн и выигрывай чаще.",
|
||||||
keywords: ["Strike Arena", "киберклуб", "компьютерный клуб", "игровые залы", "бронь ПК", "PlayStation", "турниры", "игры", "e-sports"],
|
keywords: ["Strike Arena", "киберклуб", "компьютерный клуб", "игровые залы", "бронь ПК", "PlayStation", "турниры", "игры", "e-sports"],
|
||||||
applicationName: "Strike Arena",
|
applicationName: "Strike Arena",
|
||||||
generator: "Next.js",
|
generator: "Next.js",
|
||||||
manifest: "/site.webmanifest",
|
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",
|
creator: "Strike Arena",
|
||||||
publisher: "Strike Arena",
|
publisher: "Strike Arena",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Strike Arena — Игровой клуб нового поколения",
|
title: "Strike Arena — Игровой клуб нового поколения",
|
||||||
description: "Современный киберклуб с бронью мест, турнирами, призами и VIP-комфортом.",
|
description: "Современный киберклуб с бронью мест, турнирами, призами и VIP-комфортом.",
|
||||||
url: "https://strike-arena.kz",
|
url: "https://belixa.ru",
|
||||||
siteName: "Strike Arena",
|
siteName: "Strike Arena",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
interface AdminPayload {
|
interface AdminPayload {
|
||||||
role: string
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyAdminToken(token: string | undefined): boolean {
|
export function verifyAdminToken(token: string | undefined): boolean {
|
||||||
if (!token) return false
|
if (!token) return false;
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (!jwtSecret || jwtSecret === 'replace-with-generated-secret') return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AdminPayload
|
const decoded = jwt.verify(token, jwtSecret) as AdminPayload;
|
||||||
return decoded.role === 'admin'
|
return decoded.role === 'admin';
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-12
@@ -1,23 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { jwtVerify } from "jose";
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const token = req.cookies.get("token")?.value;
|
const token = req.cookies.get('token')?.value;
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
|
||||||
if (req.nextUrl.pathname.startsWith("/admin") && req.nextUrl.pathname !== "/admin/login") {
|
if (req.nextUrl.pathname.startsWith('/admin') && req.nextUrl.pathname !== '/admin/login') {
|
||||||
if (!token) {
|
if (!token || !jwtSecret || jwtSecret === 'replace-with-generated-secret') {
|
||||||
url.pathname = "/admin/login";
|
url.pathname = '/admin/login';
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
const { payload } = await jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
||||||
if (payload.role !== "admin") throw new Error();
|
if (payload.role !== 'admin') throw new Error('Invalid role');
|
||||||
} catch {
|
} catch {
|
||||||
url.pathname = "/admin/login";
|
url.pathname = '/admin/login';
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,5 +25,5 @@ export async function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/admin", "/admin/:path*"],
|
matcher: ['/admin', '/admin/:path*'],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user