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 0000000..b24ad88 Binary files /dev/null and b/backups/polotsk_transit.dump differ 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;