517 lines
14 KiB
JavaScript
517 lines
14 KiB
JavaScript
import express from 'express';
|
||
import cors from 'cors';
|
||
import bcrypt from 'bcryptjs';
|
||
import jwt from 'jsonwebtoken';
|
||
import dotenv from 'dotenv';
|
||
import pg from 'pg';
|
||
|
||
dotenv.config();
|
||
|
||
const app = express();
|
||
const PORT = Number(process.env.PORT) || 3001;
|
||
const JWT_SECRET = process.env.JWT_SECRET;
|
||
const DATABASE_URL = process.env.DATABASE_URL;
|
||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||
const ACTIVE_WINDOW_MINUTES = Number(process.env.ACTIVE_WINDOW_MINUTES) || 15;
|
||
|
||
if (!JWT_SECRET) {
|
||
throw new Error('JWT_SECRET is not set. Add it to .env');
|
||
}
|
||
if (!DATABASE_URL) {
|
||
throw new Error('DATABASE_URL is not set. Add it to .env');
|
||
}
|
||
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
|
||
console.warn('ADMIN_USERNAME/ADMIN_PASSWORD are not set. Admin login will be disabled.');
|
||
}
|
||
|
||
const { Pool } = pg;
|
||
const pool = new Pool({ connectionString: DATABASE_URL });
|
||
|
||
// Middleware
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
|
||
const authRequired = (req, res, next) => {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
|
||
if (!token) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
message: 'Токен не предоставлен'
|
||
});
|
||
}
|
||
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET);
|
||
req.user = decoded;
|
||
next();
|
||
} catch (error) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
message: 'Недействительный токен'
|
||
});
|
||
}
|
||
};
|
||
|
||
const adminRequired = (req, res, next) => {
|
||
if (req.user?.role !== 'admin') {
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: 'Доступ запрещён'
|
||
});
|
||
}
|
||
return next();
|
||
};
|
||
|
||
// Register endpoint
|
||
app.post('/api/register', async (req, res) => {
|
||
try {
|
||
const { firstName, lastName, username, password } = req.body;
|
||
|
||
// Validation
|
||
if (!firstName || !lastName || !username || !password) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Все поля обязательны для заполнения'
|
||
});
|
||
}
|
||
|
||
// Check if user already exists
|
||
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
|
||
if (existing.rows.length > 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Пользователь с таким именем уже существует'
|
||
});
|
||
}
|
||
|
||
// Hash password
|
||
const hashedPassword = await bcrypt.hash(password, 10);
|
||
|
||
const insert = await pool.query(
|
||
'INSERT INTO users (first_name, last_name, username, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, first_name, last_name, username',
|
||
[firstName, lastName, username, hashedPassword]
|
||
);
|
||
const newUser = insert.rows[0];
|
||
|
||
res.status(201).json({
|
||
success: true,
|
||
message: 'Регистрация прошла успешно',
|
||
user: {
|
||
id: newUser.id,
|
||
firstName: newUser.first_name,
|
||
lastName: newUser.last_name,
|
||
username: newUser.username
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Registration error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: 'Internal server error'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Login endpoint
|
||
app.post('/api/login', async (req, res) => {
|
||
try {
|
||
const { username, password } = req.body;
|
||
|
||
// Validation
|
||
if (!username || !password) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Имя пользователя и пароль обязательны'
|
||
});
|
||
}
|
||
|
||
// Admin login (credentials stored in .env)
|
||
if (ADMIN_USERNAME && ADMIN_PASSWORD && username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
|
||
const token = jwt.sign(
|
||
{
|
||
userId: '00000000-0000-0000-0000-000000000000',
|
||
username,
|
||
role: 'admin'
|
||
},
|
||
JWT_SECRET,
|
||
{ expiresIn: '24h' }
|
||
);
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: 'Вход выполнен успешно',
|
||
token,
|
||
user: {
|
||
id: 'admin',
|
||
firstName: 'Администратор',
|
||
lastName: '',
|
||
username,
|
||
role: 'admin'
|
||
}
|
||
});
|
||
}
|
||
|
||
// Find user
|
||
const result = await pool.query(
|
||
'SELECT id, first_name, last_name, username, password_hash FROM users WHERE username = $1',
|
||
[username]
|
||
);
|
||
const user = result.rows[0];
|
||
if (!user) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
message: 'Неверные учетные данные'
|
||
});
|
||
}
|
||
|
||
// Check password
|
||
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||
if (!isPasswordValid) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
message: 'Неверные учетные данные'
|
||
});
|
||
}
|
||
|
||
// Generate JWT token
|
||
const token = jwt.sign(
|
||
{
|
||
userId: user.id,
|
||
username: user.username
|
||
},
|
||
JWT_SECRET,
|
||
{ expiresIn: '24h' }
|
||
);
|
||
|
||
await pool.query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [user.id]);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'Вход выполнен успешно',
|
||
token,
|
||
user: {
|
||
id: user.id,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
username: user.username
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Login error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Protected route example
|
||
app.get('/api/profile', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const result = await pool.query(
|
||
'SELECT id, first_name, last_name, username FROM users WHERE id = $1',
|
||
[userId]
|
||
);
|
||
const user = result.rows[0];
|
||
|
||
if (!user) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: 'Пользователь не найден'
|
||
});
|
||
}
|
||
|
||
return res.json({
|
||
success: true,
|
||
user: {
|
||
id: user.id,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
username: user.username
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Profile error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Get all users (for testing)
|
||
app.get('/api/users', async (req, res) => {
|
||
try {
|
||
const result = await pool.query(
|
||
'SELECT id, first_name, last_name, username, created_at FROM users ORDER BY created_at DESC'
|
||
);
|
||
|
||
const usersWithoutPasswords = result.rows.map(user => ({
|
||
id: user.id,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
username: user.username,
|
||
createdAt: user.created_at
|
||
}));
|
||
|
||
return res.json({
|
||
success: true,
|
||
users: usersWithoutPasswords
|
||
});
|
||
} catch (error) {
|
||
console.error('Users list error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Incomes
|
||
app.get('/api/incomes', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const result = await pool.query(
|
||
'SELECT id, description, amount, category, date, created_at FROM incomes WHERE user_id = $1 ORDER BY date DESC, created_at DESC',
|
||
[userId]
|
||
);
|
||
|
||
return res.json({
|
||
success: true,
|
||
incomes: result.rows
|
||
});
|
||
} catch (error) {
|
||
console.error('Get incomes error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post('/api/incomes', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const { description, amount, category, date } = req.body;
|
||
|
||
if (!description || amount === undefined || amount === null || !category || !date) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Все поля обязательны для заполнения'
|
||
});
|
||
}
|
||
|
||
const parsedAmount = Number(amount);
|
||
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Некорректная сумма'
|
||
});
|
||
}
|
||
|
||
const insert = await pool.query(
|
||
'INSERT INTO incomes (user_id, description, amount, category, date) VALUES ($1, $2, $3, $4, $5) RETURNING id, description, amount, category, date, created_at',
|
||
[userId, description, parsedAmount, category, date]
|
||
);
|
||
|
||
return res.status(201).json({
|
||
success: true,
|
||
income: insert.rows[0]
|
||
});
|
||
} catch (error) {
|
||
console.error('Create income error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
app.delete('/api/incomes/:id', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const { id } = req.params;
|
||
|
||
const result = await pool.query(
|
||
'DELETE FROM incomes WHERE id = $1 AND user_id = $2 RETURNING id',
|
||
[id, userId]
|
||
);
|
||
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: 'Запись не найдена'
|
||
});
|
||
}
|
||
|
||
return res.json({
|
||
success: true
|
||
});
|
||
} catch (error) {
|
||
console.error('Delete income error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Expenses
|
||
app.get('/api/expenses', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const result = await pool.query(
|
||
'SELECT id, description, amount, category, date, created_at FROM expenses WHERE user_id = $1 ORDER BY date DESC, created_at DESC',
|
||
[userId]
|
||
);
|
||
|
||
return res.json({
|
||
success: true,
|
||
expenses: result.rows
|
||
});
|
||
} catch (error) {
|
||
console.error('Get expenses error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post('/api/expenses', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const { description, amount, category, date } = req.body;
|
||
|
||
if (!description || amount === undefined || amount === null || !category || !date) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Все поля обязательны для заполнения'
|
||
});
|
||
}
|
||
|
||
const parsedAmount = Number(amount);
|
||
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Некорректная сумма'
|
||
});
|
||
}
|
||
|
||
const insert = await pool.query(
|
||
'INSERT INTO expenses (user_id, description, amount, category, date) VALUES ($1, $2, $3, $4, $5) RETURNING id, description, amount, category, date, created_at',
|
||
[userId, description, parsedAmount, category, date]
|
||
);
|
||
|
||
return res.status(201).json({
|
||
success: true,
|
||
expense: insert.rows[0]
|
||
});
|
||
} catch (error) {
|
||
console.error('Create expense error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
app.delete('/api/expenses/:id', authRequired, async (req, res) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const { id } = req.params;
|
||
|
||
const result = await pool.query(
|
||
'DELETE FROM expenses WHERE id = $1 AND user_id = $2 RETURNING id',
|
||
[id, userId]
|
||
);
|
||
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: 'Запись не найдена'
|
||
});
|
||
}
|
||
|
||
return res.json({
|
||
success: true
|
||
});
|
||
} catch (error) {
|
||
console.error('Delete expense error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Admin
|
||
app.get('/api/admin/users', authRequired, adminRequired, async (req, res) => {
|
||
try {
|
||
const result = await pool.query(
|
||
'SELECT id, first_name, last_name, username, created_at, last_login_at FROM users ORDER BY created_at DESC'
|
||
);
|
||
|
||
const usersList = result.rows.map(user => ({
|
||
id: user.id,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
username: user.username,
|
||
createdAt: user.created_at,
|
||
lastLoginAt: user.last_login_at
|
||
}));
|
||
|
||
return res.json({
|
||
success: true,
|
||
users: usersList
|
||
});
|
||
} catch (error) {
|
||
console.error('Admin users list error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
app.get('/api/admin/active-users', authRequired, adminRequired, async (req, res) => {
|
||
try {
|
||
const result = await pool.query(
|
||
'SELECT id, first_name, last_name, username, last_login_at FROM users WHERE last_login_at IS NOT NULL AND last_login_at > (NOW() - ($1::int * INTERVAL \'1 minute\')) ORDER BY last_login_at DESC',
|
||
[ACTIVE_WINDOW_MINUTES]
|
||
);
|
||
|
||
const usersList = result.rows.map(user => ({
|
||
id: user.id,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
username: user.username,
|
||
lastLoginAt: user.last_login_at
|
||
}));
|
||
|
||
return res.json({
|
||
success: true,
|
||
activeWindowMinutes: ACTIVE_WINDOW_MINUTES,
|
||
users: usersList
|
||
});
|
||
} catch (error) {
|
||
console.error('Admin active users error:', error);
|
||
return res.status(500).json({
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Start server
|
||
app.listen(PORT, () => {
|
||
console.log(`Server running on http://localhost:${PORT}`);
|
||
console.log('Available endpoints:');
|
||
console.log('POST /api/register - Register new user');
|
||
console.log('POST /api/login - Login user');
|
||
console.log('GET /api/profile - Get user profile (protected)');
|
||
console.log('GET /api/users - Get all users (for testing)');
|
||
});
|