2 Commits
Author SHA1 Message Date
prog1764 207ec7e56b docker: подготовка к запуску в контейнере (Next.js)
- Dockerfile: multi-stage (deps/build/runner), standalone-сборка, non-root user
- next.config.mjs: output: 'standalone'
- docker-compose.yml: порт 3000, env из .env.local
- .dockerignore: node_modules, .next, .env*, кэш
- проверено: npm run build прошёл, standalone артефакт на месте
2026-08-04 20:23:16 +04:00
prog1764 d920f1c559 fix: убрать утёкший токен, обновить Next.js, rate limit на /api/contact (PRJ-4)
- .env.local.example: реальный TELEGRAM_BOT_TOKEN заменён на placeholder (токен скомпрометирован — был в git-истории)
- next 14.2.5 -> 14.2.35 (закрывает известные CVE), eslint-config-next синхронно
- /api/contact: rate limit 5 запросов / 10 минут по IP (in-memory sliding window) + 429
- build проверен: 12 страниц, ошибок нет
2026-08-04 19:43:00 +04:00
8 changed files with 6450 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.idea
node_modules
.next
out
.env*
.vercel
*.log
.next
npm-debug.log*
+5
View File
@@ -1,6 +1,11 @@
# Скопируйте этот файл в .env.local и заполните реальными значениями. # Скопируйте этот файл в .env.local и заполните реальными значениями.
# .env.local никогда не должен попадать в git (уже добавлен в .gitignore). # .env.local никогда не должен попадать в git (уже добавлен в .gitignore).
# ВАЖНО: предыдущий токен (your_new_bot_token_from_botfather)
# был закоммичен в git и считается скомпрометированным.
# Его нужно отозвать через @BotFather (/revoke) и вписать сюда новый.
# В репозиторий реальные секреты не класть!
# Токен вашего Telegram-бота, полученный у @BotFather # Токен вашего Telegram-бота, полученный у @BotFather
TELEGRAM_BOT_TOKEN=your_new_bot_token_from_botfather TELEGRAM_BOT_TOKEN=your_new_bot_token_from_botfather
+33
View File
@@ -0,0 +1,33 @@
# ---- deps ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- runtime ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+45
View File
@@ -8,6 +8,42 @@ const MAX_LENGTHS = {
message: 2000, message: 2000,
}; };
// --- Rate limiting (in-memory, per IP) ---
// Простой sliding-window лимитер без внешних зависимостей.
// Для продакшена с несколькими инстансами замените на Redis/внешний стор.
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 минут
const RATE_LIMIT_MAX = 5; // максимум 5 запросов за окно
const rateStore = new Map<string, { count: number; windowStart: number }>();
function getClientIp(req: NextRequest): string {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
return req.headers.get("x-real-ip") ?? "unknown";
}
function isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = rateStore.get(ip);
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
rateStore.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT_MAX;
}
// Периодическая очистка устаревших записей, чтобы Map не рос бесконечно.
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of rateStore) {
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
rateStore.delete(ip);
}
}
}, RATE_LIMIT_WINDOW_MS).unref?.();
// Убираем HTML-теги и обрезаем длину, чтобы нельзя было сломать // Убираем HTML-теги и обрезаем длину, чтобы нельзя было сломать
// форматирование сообщения в Telegram или протащить лишний код. // форматирование сообщения в Telegram или протащить лишний код.
function sanitize(value: unknown, maxLength: number): string { function sanitize(value: unknown, maxLength: number): string {
@@ -21,6 +57,15 @@ function escapeMarkdown(text: string): string {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
console.warn(`Rate limit exceeded for ${ip}`);
return NextResponse.json(
{ ok: false, error: "Слишком много запросов. Попробуйте позже" },
{ status: 429 }
);
}
const token = process.env.TELEGRAM_BOT_TOKEN; const token = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID; const chatId = process.env.TELEGRAM_CHAT_ID;
+9
View File
@@ -0,0 +1,9 @@
# Секреты — только из .env (см. .env.local.example)
services:
web:
build: .
ports:
- "3000:3000"
env_file:
- .env.local
restart: unless-stopped
+1
View File
@@ -17,6 +17,7 @@ const securityHeaders = [
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false, poweredByHeader: false,
output: "standalone",
async headers() { async headers() {
return [ return [
{ {
+6344
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -9,7 +9,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"next": "^14.2.5", "next": "^14.2.35",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"lucide-react": "^0.427.0" "lucide-react": "^0.427.0"
@@ -23,6 +23,6 @@
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "^14.2.5" "eslint-config-next": "^14.2.35"
} }
} }