From 66c84ecdeb5b777f14dfa11fd490df28fe8e4b28 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 29 Apr 2026 00:55:00 +0300 Subject: [PATCH] Fix initial user creating --- .env.example | 12 ++- QUICKSTART.md | 4 +- README.md | 9 +- backend/package.json | 1 + backend/scripts/create-admin.js | 30 +++---- backend/src/index.js | 24 ++++++ backend/src/utils/bootstrapAdmin.js | 122 ++++++++++++++++++++++++++++ backups/polotsk_transit.dump | Bin 0 -> 69145 bytes docker-compose.yml | 28 +++++-- frontend/.env.example | 2 +- frontend/Dockerfile | 2 +- frontend/nginx.conf | 22 +++++ frontend/src/pages/LoginPage.jsx | 4 +- frontend/src/utils/apiBaseUrl.js | 4 +- frontend/vite.config.js | 12 ++- nginx/nginx.conf | 26 ++++++ 16 files changed, 267 insertions(+), 35 deletions(-) create mode 100644 backend/src/utils/bootstrapAdmin.js create mode 100644 backups/polotsk_transit.dump diff --git a/.env.example b/.env.example index 590aa22..59cd440 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,18 @@ DB_PASSWORD=your_secure_password_here API_KEY_SALT=generate_random_salt_with_openssl CORS_ORIGIN=http://localhost:3001 +# Bootstrap admin +BOOTSTRAP_ADMIN=true +BOOTSTRAP_ADMIN_RESET_PASSWORD=false +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change_this_admin_password +ADMIN_FULL_NAME=Main administrator +ADMIN_EMAIL=admin@polotsk-transit.local + # Frontend -API_URL=http://localhost:3000/api/v1 +API_URL=/api/v1 # Production settings (uncomment for production) # NODE_ENV=production # CORS_ORIGIN=https://admin.yourdomain.com -# API_URL=https://api.yourdomain.com/api/v1 +# API_URL=/api/v1 diff --git a/QUICKSTART.md b/QUICKSTART.md index 100acd5..0c37167 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -26,7 +26,7 @@ cp .env.example .env # Сгенерируйте безопасный пароль openssl rand -base64 32 -# Отредактируйте .env и вставьте сгенерированный пароль в DB_PASSWORD +# Отредактируйте .env и вставьте сгенерированные пароли в DB_PASSWORD и ADMIN_PASSWORD nano .env ``` @@ -56,6 +56,8 @@ curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/sync/status ### Шаг 5: Откройте админ-панель Откройте в браузере: http://localhost:3001 +Войдите под `admin` и паролем из `ADMIN_PASSWORD` в `.env`. + **Готово! 🎉** --- diff --git a/README.md b/README.md index b26ab56..50ba590 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ nano .env # Обязательно измените в продакшене! DB_PASSWORD=your_secure_password_here API_KEY_SALT=generate_random_salt_with_openssl +ADMIN_PASSWORD=your_secure_admin_password_here -# Для разработки оставьте как есть +# Для разработки/деплоя через встроенный proxy оставьте как есть CORS_ORIGIN=http://localhost:3001 -API_URL=http://localhost:3000/api/v1 +API_URL=/api/v1 ``` Генерация безопасного salt: @@ -82,6 +83,10 @@ docker-compose logs -f - **PostgreSQL**: localhost:5432 - **Redis**: localhost:6379 +Мастер-аккаунт создается автоматически при старте backend: +- login: `admin` +- password: значение `ADMIN_PASSWORD` из `.env` (если не задано, fallback `admin123`) + ### 3. Проверка работоспособности ```bash diff --git a/backend/package.json b/backend/package.json index 36201f2..df048c7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "start": "node src/index.js", "dev": "nodemon src/index.js", "migrate": "node src/utils/migrate.js", + "create-admin": "node scripts/create-admin.js", "seed": "node src/utils/seed.js" }, "keywords": [ diff --git a/backend/scripts/create-admin.js b/backend/scripts/create-admin.js index 3702eaa..bf8a5f8 100644 --- a/backend/scripts/create-admin.js +++ b/backend/scripts/create-admin.js @@ -1,29 +1,25 @@ -const bcrypt = require('bcrypt'); const pool = require('../src/config/database'); +const { ensureBootstrapAdmin, readBootstrapAdminConfig } = require('../src/utils/bootstrapAdmin'); async function createAdmin() { - const username = 'admin'; - const password = 'admin123'; // Измените после первого входа! - const passwordHash = await bcrypt.hash(password, 10); + const config = readBootstrapAdminConfig(); try { - const result = await pool.query(` - INSERT INTO users (username, password_hash, full_name, email, role, is_active) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (username) DO UPDATE - SET password_hash = $2 - RETURNING id, username, role - `, [username, passwordHash, 'Главный администратор', 'admin@polotsk-transit.local', 'admin', true]); + const result = await ensureBootstrapAdmin({ + resetPassword: true, + createIfActiveAdminExists: true, + }); - console.log('✅ Мастер-аккаунт создан:', result.rows[0]); - console.log('Username: admin'); - console.log('Password: admin123'); - console.log('⚠️ ОБЯЗАТЕЛЬНО смените пароль после первого входа!'); + console.log('Master account is ready:', result.user); + console.log(`Username: ${config.username}`); + console.log(`Password: ${config.password}`); + console.log('Change this password after the first login.'); } catch (error) { - console.error('❌ Ошибка:', error); + console.error('Error:', error); + process.exitCode = 1; } finally { await pool.end(); } } -createAdmin(); \ No newline at end of file +createAdmin(); diff --git a/backend/src/index.js b/backend/src/index.js index 57624aa..f1ed475 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -5,11 +5,13 @@ const compression = require('compression'); require('dotenv').config(); const logger = require('./config/logger'); +const pool = require('./config/database'); const apiRoutes = require('./routes/api'); const { authenticateApiKey } = require('./middleware/auth'); const createRateLimiter = require('./middleware/rateLimiter'); const { ensureRealtimeSchema } = require('./services/realtimeSchema'); const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService'); +const { ensureBootstrapAdmin } = require('./utils/bootstrapAdmin'); const app = express(); const PORT = process.env.PORT || 3000; @@ -133,9 +135,31 @@ function shutdown(signal) { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForDatabase({ attempts = 30, delayMs = 1000 } = {}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await pool.query('SELECT 1'); + return; + } catch (error) { + if (attempt === attempts) { + throw error; + } + + logger.warn(`Database is not ready yet (${attempt}/${attempts}); retrying...`); + await sleep(delayMs); + } + } +} + async function startServer() { try { + await waitForDatabase(); await ensureRealtimeSchema(); + await ensureBootstrapAdmin(); server = app.listen(PORT, () => { logger.info(`Server is running on port ${PORT}`); diff --git a/backend/src/utils/bootstrapAdmin.js b/backend/src/utils/bootstrapAdmin.js new file mode 100644 index 0000000..5ea8334 --- /dev/null +++ b/backend/src/utils/bootstrapAdmin.js @@ -0,0 +1,122 @@ +const bcrypt = require('bcrypt'); + +const pool = require('../config/database'); +const logger = require('../config/logger'); + +const DEFAULT_ADMIN = { + username: 'admin', + password: 'admin123', + fullName: 'Main administrator', + email: 'admin@polotsk-transit.local', +}; + +function readBoolean(value, defaultValue) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function readBootstrapAdminConfig(env = process.env) { + return { + enabled: readBoolean(env.BOOTSTRAP_ADMIN, true), + username: env.ADMIN_USERNAME || DEFAULT_ADMIN.username, + password: env.ADMIN_PASSWORD || DEFAULT_ADMIN.password, + fullName: env.ADMIN_FULL_NAME || DEFAULT_ADMIN.fullName, + email: env.ADMIN_EMAIL || DEFAULT_ADMIN.email, + resetPassword: readBoolean(env.BOOTSTRAP_ADMIN_RESET_PASSWORD, false), + }; +} + +async function activeAdminExists() { + const result = await pool.query(` + SELECT EXISTS ( + SELECT 1 + FROM users + WHERE role = 'admin' AND is_active = true + ) AS exists + `); + + return result.rows[0]?.exists === true; +} + +function validateConfig(config) { + if (!config.username) { + throw new Error('ADMIN_USERNAME cannot be empty'); + } + + if (!config.password || config.password.length < 6) { + throw new Error('ADMIN_PASSWORD must be at least 6 characters'); + } +} + +async function ensureBootstrapAdmin(options = {}) { + const config = { + ...readBootstrapAdminConfig(), + ...options, + }; + + if (!config.enabled) { + logger.info('Bootstrap admin is disabled'); + return { changed: false, reason: 'disabled' }; + } + + validateConfig(config); + + const existingResult = await pool.query( + 'SELECT id, username, role, is_active FROM users WHERE username = $1', + [config.username] + ); + const existingUser = existingResult.rows[0]; + + if (existingUser) { + const needsRepair = existingUser.role !== 'admin' || existingUser.is_active !== true; + const shouldResetPassword = config.resetPassword || needsRepair; + + if (!needsRepair && !shouldResetPassword) { + logger.info(`Bootstrap admin already exists: ${config.username}`); + return { changed: false, user: existingUser }; + } + + const passwordHash = shouldResetPassword + ? await bcrypt.hash(config.password, 10) + : null; + + const updateResult = await pool.query(` + UPDATE users + SET role = 'admin', + is_active = true, + full_name = COALESCE($2, full_name), + email = COALESCE($3, email), + password_hash = COALESCE($4, password_hash) + WHERE username = $1 + RETURNING id, username, role, is_active + `, [config.username, config.fullName || null, config.email || null, passwordHash]); + + logger.info(`Bootstrap admin repaired: ${config.username}`); + return { changed: true, user: updateResult.rows[0] }; + } + + const shouldCreateWithExistingAdmin = options.createIfActiveAdminExists === true; + if (!shouldCreateWithExistingAdmin && await activeAdminExists()) { + logger.info('Active admin already exists; default bootstrap admin was not created'); + return { changed: false, reason: 'active-admin-exists' }; + } + + const passwordHash = await bcrypt.hash(config.password, 10); + const insertResult = await pool.query(` + INSERT INTO users (username, password_hash, full_name, email, role, is_active) + VALUES ($1, $2, $3, $4, 'admin', true) + RETURNING id, username, role, is_active + `, [config.username, passwordHash, config.fullName || null, config.email || null]); + + logger.info(`Bootstrap admin created: ${config.username}`); + return { changed: true, user: insertResult.rows[0] }; +} + +module.exports = { + DEFAULT_ADMIN, + ensureBootstrapAdmin, + readBootstrapAdminConfig, +}; diff --git a/backups/polotsk_transit.dump b/backups/polotsk_transit.dump new file mode 100644 index 0000000000000000000000000000000000000000..b24ad88f624ef49b3c26904929dcb9e8bbed91f0 GIT binary patch literal 69145 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 RadB}0040GWQUCw|000u&Xz~C6 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index 588269b..94a83b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgis/postgis:15-3.3 @@ -13,6 +11,11 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ./backend/migrations:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U transit_admin -d polotsk_transit"] + interval: 5s + timeout: 5s + retries: 20 networks: - transit-network restart: unless-stopped @@ -24,6 +27,11 @@ services: - "6379:6379" volumes: - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 networks: - transit-network restart: unless-stopped @@ -41,11 +49,19 @@ services: API_KEY_SALT: ${API_KEY_SALT:-generate_random_salt} JWT_SECRET: ${JWT_SECRET:-change_me_in_production_use_random_string} CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001} + BOOTSTRAP_ADMIN: ${BOOTSTRAP_ADMIN:-true} + BOOTSTRAP_ADMIN_RESET_PASSWORD: ${BOOTSTRAP_ADMIN_RESET_PASSWORD:-false} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local} ports: - "3000:3000" depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_healthy networks: - transit-network restart: unless-stopped @@ -58,10 +74,10 @@ services: context: ./frontend dockerfile: Dockerfile args: - VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1} + VITE_API_URL: ${API_URL:-/api/v1} container_name: polotsk-transit-admin environment: - VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1} + VITE_API_URL: ${API_URL:-/api/v1} ports: - "3001:80" depends_on: diff --git a/frontend/.env.example b/frontend/.env.example index 102aaa7..0128a1f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,2 @@ -VITE_API_URL=http://localhost:3000/api/v1 +VITE_API_URL=/api/v1 VITE_API_KEY=dev_key_12345678 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 43b3a3f..47a4876 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,7 +3,7 @@ FROM node:18-alpine as build WORKDIR /app -ARG VITE_API_URL=http://localhost:3000/api/v1 +ARG VITE_API_URL=/api/v1 ENV VITE_API_URL=$VITE_API_URL COPY package*.json ./ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 08a0608..8a3a50c 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -10,6 +10,28 @@ server { gzip_min_length 1000; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + # Same-origin API proxy for deployed admin panel + location /api/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ws/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + } + # SPA routing location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 4103567..4822054 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -117,7 +117,7 @@ export default function LoginPage() { Мастер-аккаунт по умолчанию:

- admin / admin123 + admin / ADMIN_PASSWORD

@@ -129,4 +129,4 @@ export default function LoginPage() { ); -} \ No newline at end of file +} diff --git a/frontend/src/utils/apiBaseUrl.js b/frontend/src/utils/apiBaseUrl.js index 5232323..2cdffc5 100644 --- a/frontend/src/utils/apiBaseUrl.js +++ b/frontend/src/utils/apiBaseUrl.js @@ -1,4 +1,4 @@ -const DEFAULT_API_BASE_URL = 'http://localhost:3000/api/v1'; +const DEFAULT_API_BASE_URL = '/api/v1'; function normalizePath(pathname) { const trimmedPath = (pathname || '').replace(/\/+$/, ''); @@ -40,6 +40,6 @@ export function getApiOrigin(rawValue = import.meta.env.VITE_API_URL || DEFAULT_ const url = new URL(getApiBaseUrl(rawValue)); return `${url.protocol}//${url.host}`; } catch (error) { - return 'http://localhost:3000'; + return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'; } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 33b341d..3c16881 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,7 +5,17 @@ export default defineConfig({ plugins: [react()], server: { host: true, - port: 3001 + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + }, + '/ws': { + target: 'ws://localhost:3000', + ws: true + } + } }, build: { outDir: 'dist', diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 77d1a14..3f911d7 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -104,6 +104,32 @@ http { access_log /var/log/nginx/admin_access.log; error_log /var/log/nginx/admin_error.log; + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /ws/ { + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + } + location / { limit_req zone=admin_limit burst=10 nodelay;