NoteJS/server.js
Fovway df2c94dc0e Обновлены функции обработки IP-адресов и улучшен интерфейс редактирования заметок
- Настроена обработка IP-адресов с учетом доверенных прокси и логирование для отладки.
- Добавлены функции для отображения и удаления существующих изображений при редактировании заметок.
- Оптимизированы обработчики событий для кнопок удаления изображений, теперь они доступны только в режиме редактирования.
- Обновлены стили и структура интерфейса для улучшения пользовательского опыта.
2025-10-26 06:47:27 +07:00

1726 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require("express");
const sqlite3 = require("sqlite3").verbose();
const bcrypt = require("bcryptjs");
const session = require("express-session");
const SQLiteStore = require("connect-sqlite3")(session);
const path = require("path");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const bodyParser = require("body-parser");
const multer = require("multer");
const fs = require("fs");
require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 3000;
// Настройка trust proxy для правильного получения IP адресов через прокси
// Доверяем localhost и стандартной Docker bridge сети
app.set("trust proxy", [
"127.0.0.1",
"::1",
"172.17.0.0/16",
"10.0.0.0/8",
"192.168.0.0/16",
]);
// Создаем директорию для аватарок, если её нет
const uploadsDir = path.join(__dirname, "public", "uploads");
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Создаем директорию для баз данных, если её нет
const databaseDir = path.join(__dirname, "database");
if (!fs.existsSync(databaseDir)) {
fs.mkdirSync(databaseDir, { recursive: true });
}
// Настройка multer для загрузки аватарок
const avatarStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(
null,
"avatar-" +
req.session.userId +
"-" +
uniqueSuffix +
path.extname(file.originalname)
);
},
});
// Настройка multer для загрузки изображений заметок
const noteImageStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(
null,
"note-image-" +
req.session.userId +
"-" +
uniqueSuffix +
path.extname(file.originalname)
);
},
});
const upload = multer({
storage: avatarStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB максимум
fileFilter: function (req, file, cb) {
const filetypes = /jpeg|jpg|png|gif/;
const mimetype = filetypes.test(file.mimetype);
const extname = filetypes.test(
path.extname(file.originalname).toLowerCase()
);
if (mimetype && extname) {
return cb(null, true);
}
cb(new Error("Только изображения (jpeg, jpg, png, gif) разрешены!"));
},
});
const uploadNoteImages = multer({
storage: noteImageStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB максимум для изображений заметок
fileFilter: function (req, file, cb) {
const filetypes = /jpeg|jpg|png|gif|webp/;
const mimetype = filetypes.test(file.mimetype);
const extname = filetypes.test(
path.extname(file.originalname).toLowerCase()
);
if (mimetype && extname) {
return cb(null, true);
}
cb(new Error("Только изображения (jpeg, jpg, png, gif, webp) разрешены!"));
},
});
// Middleware для безопасности
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
"https://cdnjs.cloudflare.com",
],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://cdnjs.cloudflare.com", "data:"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: [
"'self'",
"https://cdnjs.cloudflare.com",
"https://api.iconify.design",
"https://api.simplesvg.com",
"https://api.unisvg.com",
],
},
},
})
);
// Ограничение запросов (отключено для разработки)
// const limiter = rateLimit({
// windowMs: 15 * 60 * 1000, // 15 минут
// max: 100, // максимум 100 запросов с одного IP
// });
// app.use(limiter);
// Статические файлы
app.use(express.static(path.join(__dirname, "public")));
// PWA файлы с правильными заголовками
app.get("/manifest.json", (req, res) => {
res.setHeader("Content-Type", "application/manifest+json");
res.setHeader("Cache-Control", "public, max-age=86400"); // 24 часа
res.sendFile(path.join(__dirname, "public", "manifest.json"));
});
app.get("/sw.js", (req, res) => {
res.setHeader("Content-Type", "application/javascript");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.sendFile(path.join(__dirname, "public", "sw.js"));
});
app.get("/browserconfig.xml", (req, res) => {
res.setHeader("Content-Type", "application/xml");
res.setHeader("Cache-Control", "public, max-age=86400"); // 24 часа
res.sendFile(path.join(__dirname, "public", "browserconfig.xml"));
});
// Парсинг тела запроса
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// Настройка сессий с хранением в SQLite
app.use(
session({
store: new SQLiteStore({
db: "sessions.db",
table: "sessions",
dir: path.join(__dirname, "database"),
}),
secret: process.env.SESSION_SECRET || "default-secret",
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // в продакшене установить true с HTTPS
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
},
})
);
// Подключение к базе данных
const db = new sqlite3.Database("./database/notes.db", (err) => {
if (err) {
console.error("Ошибка подключения к базе данных:", err.message);
} else {
console.log("Подключено к SQLite базе данных");
createTables();
}
});
// Создание таблиц
function createTables() {
const createNotesTable = `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`;
const createNoteImagesTable = `
CREATE TABLE IF NOT EXISTS note_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
)
`;
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email TEXT,
avatar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
db.run(createNotesTable, (err) => {
if (err) {
console.error("Ошибка создания таблицы заметок:", err.message);
} else {
console.log("Таблица заметок готова");
}
});
db.run(createNoteImagesTable, (err) => {
if (err) {
console.error(
"Ошибка создания таблицы изображений заметок:",
err.message
);
} else {
console.log("Таблица изображений заметок готова");
}
});
db.run(createUsersTable, (err) => {
if (err) {
console.error("Ошибка создания таблицы пользователей:", err.message);
} else {
console.log("Таблица пользователей готова");
runMigrations();
}
});
}
// Создание индексов для оптимизации запросов
function createIndexes() {
const indexes = [
"CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id)",
"CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at)",
"CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at)",
"CREATE INDEX IF NOT EXISTS idx_notes_date ON notes(date)",
"CREATE INDEX IF NOT EXISTS idx_notes_is_pinned ON notes(is_pinned)",
"CREATE INDEX IF NOT EXISTS idx_notes_is_archived ON notes(is_archived)",
"CREATE INDEX IF NOT EXISTS idx_note_images_note_id ON note_images(note_id)",
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)",
"CREATE INDEX IF NOT EXISTS idx_action_logs_user_id ON action_logs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_action_logs_created_at ON action_logs(created_at)",
];
indexes.forEach((indexSql, i) => {
db.run(indexSql, (err) => {
if (err) {
console.error(`Ошибка создания индекса ${i + 1}:`, err.message);
} else {
console.log(`Индекс ${i + 1} создан успешно`);
}
});
});
}
// Функция для логирования действий пользователя
function logAction(userId, actionType, details, ipAddress) {
const sql = `
INSERT INTO action_logs (user_id, action_type, details, ip_address)
VALUES (?, ?, ?, ?)
`;
db.run(sql, [userId, actionType, details, ipAddress], (err) => {
if (err) {
console.error("Ошибка логирования действия:", err.message);
}
});
}
// Функция для получения IP-адреса клиента
function getClientIP(req) {
// Для отладки логируем все релевантные заголовки и IP адреса
const debugInfo = {
"req.ip": req.ip,
"req.connection.remoteAddress": req.connection?.remoteAddress,
"x-forwarded-for": req.headers["x-forwarded-for"],
"x-real-ip": req.headers["x-real-ip"],
"x-client-ip": req.headers["x-client-ip"],
"cf-connecting-ip": req.headers["cf-connecting-ip"],
};
console.log("IP Debug info:", JSON.stringify(debugInfo, null, 2));
// Используем встроенный в Express метод req.ip, который учитывает trust proxy
let ip = req.ip;
// Если req.ip вернул undefined или является локальным/Docker адресом, пробуем другие варианты
if (
!ip ||
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "172.17.0.1" ||
ip.startsWith("172.17.") ||
ip.startsWith("10.") ||
ip.startsWith("192.168.")
) {
// Проверяем заголовки прокси по приоритету
ip =
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
req.headers["x-real-ip"] ||
req.headers["x-client-ip"] ||
req.headers["cf-connecting-ip"] || // Для Cloudflare
req.headers["x-cluster-client-ip"] || // Для кластеров
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
"unknown";
}
// Очищаем IP от скобок IPv6 и портов
if (ip && ip !== "unknown") {
// Убираем скобки IPv6
ip = ip.replace(/[[\]]/g, "");
// Проверяем, является ли это IPv6 адресом
if (ip.includes("::")) {
// Это IPv6 адрес (содержит "::" или несколько ":")
// IPv6 адреса могут быть в формате [::1]:port или ::1
const ipv6Match = ip.match(/^(\[)?([^\]]+)(\])?(:(\d+))?$/);
if (ipv6Match) {
ip = ipv6Match[2]; // Берем IPv6 адрес без скобок и порта
}
} else if (ip.includes(":") && !ip.includes(".")) {
// IPv6 адрес без "::" но с несколькими ":"
// Например, 2001:db8::1
// Оставляем как есть
} else if (ip.includes(":")) {
// IPv4 с портом (например, 192.168.1.1:3000)
const parts = ip.split(":");
if (parts.length === 2 && /^\d+$/.test(parts[1])) {
ip = parts[0];
}
}
// IPv4 без порта оставляем как есть
}
// Конвертируем IPv6 localhost в IPv4 для лучшей читаемости
if (ip === "::1") {
ip = "127.0.0.1";
}
// Финальный IP для логов
const finalIP = ip || "unknown";
console.log(`Final IP for logging: ${finalIP}`);
return finalIP;
}
// Миграции базы данных
function runMigrations() {
// Создаем таблицу логов действий, если её нет
const createLogsTable = `
CREATE TABLE IF NOT EXISTS action_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
action_type TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`;
db.run(createLogsTable, (err) => {
if (err) {
console.error("Ошибка создания таблицы логов:", err.message);
} else {
console.log("Таблица action_logs готова");
}
});
// Проверяем существование колонки accent_color и добавляем её если нужно
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
console.error("Ошибка проверки структуры таблицы users:", err.message);
return;
}
const hasAccentColor = columns.some((col) => col.name === "accent_color");
if (!hasAccentColor) {
db.run(
"ALTER TABLE users ADD COLUMN accent_color TEXT DEFAULT '#007bff'",
(err) => {
if (err) {
console.error(
"Ошибка добавления колонки accent_color:",
err.message
);
} else {
console.log("Колонка accent_color добавлена в таблицу users");
}
}
);
}
});
// Проверяем существование колонок в таблице notes и добавляем их если нужно
db.all("PRAGMA table_info(notes)", (err, columns) => {
if (err) {
console.error("Ошибка проверки структуры таблицы notes:", err.message);
return;
}
const hasUpdatedAt = columns.some((col) => col.name === "updated_at");
const hasPinned = columns.some((col) => col.name === "is_pinned");
const hasArchived = columns.some((col) => col.name === "is_archived");
// Добавляем updated_at если нужно
if (!hasUpdatedAt) {
db.run("ALTER TABLE notes ADD COLUMN updated_at DATETIME", (err) => {
if (err) {
console.error("Ошибка добавления колонки updated_at:", err.message);
} else {
console.log("Колонка updated_at добавлена в таблицу notes");
db.run(
"UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL",
(err) => {
if (err) {
console.error(
"Ошибка обновления updated_at для существующих записей:",
err.message
);
}
}
);
}
});
}
// Добавляем is_pinned если нужно
if (!hasPinned) {
db.run(
"ALTER TABLE notes ADD COLUMN is_pinned INTEGER DEFAULT 0",
(err) => {
if (err) {
console.error("Ошибка добавления колонки is_pinned:", err.message);
} else {
console.log("Колонка is_pinned добавлена в таблицу notes");
}
}
);
}
// Добавляем is_archived если нужно
if (!hasArchived) {
db.run(
"ALTER TABLE notes ADD COLUMN is_archived INTEGER DEFAULT 0",
(err) => {
if (err) {
console.error(
"Ошибка добавления колонки is_archived:",
err.message
);
} else {
console.log("Колонка is_archived добавлена в таблицу notes");
}
}
);
}
// Создаем индексы после всех изменений
if (hasUpdatedAt && hasPinned && hasArchived) {
createIndexes();
} else {
// Задержка для завершения миграций
setTimeout(createIndexes, 1000);
}
});
}
// Middleware для аутентификации
function requireAuth(req, res, next) {
if (req.session.authenticated) {
return next();
} else {
return res.redirect("/");
}
}
// Маршруты
// Главная страница с формой входа
app.get("/", (req, res) => {
if (req.session.authenticated) {
return res.redirect("/notes");
}
res.sendFile(path.join(__dirname, "public", "index.html"));
});
// Страница регистрации
app.get("/register", (req, res) => {
if (req.session.authenticated) {
return res.redirect("/notes");
}
res.sendFile(path.join(__dirname, "public", "register.html"));
});
// API регистрации
app.post("/api/register", async (req, res) => {
const { username, password, confirmPassword } = req.body;
// Валидация
if (!username || !password || !confirmPassword) {
return res.status(400).json({ error: "Все поля обязательны" });
}
if (username.length < 3) {
return res
.status(400)
.json({ error: "Логин должен быть не менее 3 символов" });
}
if (password.length < 6) {
return res
.status(400)
.json({ error: "Пароль должен быть не менее 6 символов" });
}
if (password !== confirmPassword) {
return res.status(400).json({ error: "Пароли не совпадают" });
}
try {
// Хешируем пароль
const hashedPassword = await bcrypt.hash(password, 10);
// Вставляем пользователя в БД
const sql = "INSERT INTO users (username, password) VALUES (?, ?)";
db.run(sql, [username, hashedPassword], function (err) {
if (err) {
if (err.message.includes("UNIQUE constraint failed")) {
return res.status(400).json({ error: "Этот логин уже занят" });
}
console.error("Ошибка регистрации:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Автоматически логиним пользователя после регистрации
req.session.userId = this.lastID;
req.session.username = username;
req.session.authenticated = true;
// Логируем регистрацию
const clientIP = getClientIP(req);
logAction(
this.lastID,
"register",
`Регистрация нового пользователя`,
clientIP
);
res.json({ success: true, message: "Регистрация успешна" });
});
} catch (err) {
console.error("Ошибка при хешировании:", err);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// API входа
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: "Логин и пароль обязательны" });
}
const sql = "SELECT * FROM users WHERE username = ?";
db.get(sql, [username], async (err, user) => {
if (err) {
console.error("Ошибка входа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(401).json({ error: "Неверный логин или пароль" });
}
try {
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: "Неверный логин или пароль" });
}
// Успешный вход
req.session.userId = user.id;
req.session.username = user.username;
req.session.authenticated = true;
// Логируем вход
const clientIP = getClientIP(req);
logAction(user.id, "login", `Вход в систему`, clientIP);
res.json({ success: true, message: "Вход успешен" });
} catch (err) {
console.error("Ошибка при сравнении паролей:", err);
res.status(500).json({ error: "Ошибка сервера" });
}
});
});
// Обработка входа (старый маршрут для совместимости)
app.post("/login", async (req, res) => {
const { password } = req.body;
const correctPassword = process.env.APP_PASSWORD;
if (password === correctPassword) {
req.session.authenticated = true;
res.redirect("/notes");
} else {
res.redirect("/?error=invalid_password");
}
});
// API для проверки статуса аутентификации
app.get("/api/auth/status", (req, res) => {
if (req.session.authenticated && req.session.userId) {
res.json({
authenticated: true,
userId: req.session.userId,
username: req.session.username,
});
} else {
res.status(401).json({ authenticated: false });
}
});
// API для получения информации о пользователе
app.get("/api/user", requireAuth, (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Не аутентифицирован" });
}
const sql =
"SELECT username, email, avatar, accent_color FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, user) => {
if (err) {
console.error("Ошибка получения данных пользователя:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
res.json(user);
});
});
// Страница с заметками (требует аутентификации)
app.get("/notes", requireAuth, (req, res) => {
// Получаем цвет пользователя для предотвращения FOUC
const sql = "SELECT accent_color FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, user) => {
if (err) {
console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "notes.html"));
}
const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл
fs.readFile(
path.join(__dirname, "public", "notes.html"),
"utf8",
(err, html) => {
if (err) {
console.error("Ошибка чтения файла notes.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "notes.html"));
}
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
const inlineCSS = `<style>
:root, html { --accent-color: ${accentColor} !important; }
* { --accent-color: ${accentColor} !important; }
</style>`;
const modifiedHtml = html.replace(
/<head>/i,
`<head>\n ${inlineCSS}`
);
res.send(modifiedHtml);
}
);
});
});
// API для поиска заметок с изображениями (должен быть ПЕРЕД /api/notes/:id)
app.get("/api/notes/search", requireAuth, (req, res) => {
const { q, tag, date } = req.query;
let whereClause = "WHERE n.user_id = ?";
let params = [req.session.userId];
// Поиск по тексту
if (q && q.trim()) {
whereClause += " AND n.content LIKE ?";
params.push(`%${q.trim()}%`);
}
// Поиск по тегу
if (tag && tag.trim()) {
whereClause += " AND n.content LIKE ?";
params.push(`%#${tag.trim()}%`);
}
// Поиск по дате (используем created_at вместо date)
if (date && date.trim()) {
whereClause += " AND strftime('%d.%m.%Y', n.created_at) = ?";
params.push(date.trim());
}
const sql = `
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
'file_path', ni.file_path,
'file_size', ni.file_size,
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
${whereClause}
GROUP BY n.id
ORDER BY n.created_at DESC
`;
db.all(sql, params, (err, rows) => {
if (err) {
console.error("Ошибка поиска заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
}));
res.json(notesWithImages);
});
});
// API для получения всех заметок с изображениями (исключая архивные)
app.get("/api/notes", requireAuth, (req, res) => {
const sql = `
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
'file_path', ni.file_path,
'file_size', ni.file_size,
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
WHERE n.user_id = ? AND n.is_archived = 0
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.created_at DESC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error("Ошибка получения заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
}));
res.json(notesWithImages);
});
});
// API для создания новой заметки
app.post("/api/notes", requireAuth, (req, res) => {
const { content, date, time } = req.body;
if (!content || !date || !time) {
return res.status(400).json({ error: "Не все поля заполнены" });
}
const sql =
"INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)";
const params = [req.session.userId, content, date, time];
db.run(sql, params, function (err) {
if (err) {
console.error("Ошибка создания заметки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем создание заметки
const clientIP = getClientIP(req);
const noteId = this.lastID;
logAction(
req.session.userId,
"note_create",
`Создана заметка #${noteId}`,
clientIP
);
res.json({ id: noteId, content, date, time });
});
});
// API для обновления заметки
app.put("/api/notes/:id", requireAuth, (req, res) => {
const { content } = req.body;
const { id } = req.params;
if (!content) {
return res.status(400).json({ error: "Содержание заметки обязательно" });
}
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
const updateSql =
"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
const params = [content, id];
db.run(updateSql, params, function (err) {
if (err) {
console.error("Ошибка обновления заметки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Заметка не найдена" });
}
// Логируем обновление заметки
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_update",
`Обновлена заметка #${id}`,
clientIP
);
res.json({ id, content, date: row.date, time: row.time });
});
});
});
// API для удаления заметки
app.delete("/api/notes/:id", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
// Сначала удаляем все изображения заметки
const getImagesSql = "SELECT file_path FROM note_images WHERE note_id = ?";
db.all(getImagesSql, [id], (err, images) => {
if (err) {
console.error("Ошибка получения изображений:", err.message);
} else {
// Удаляем файлы изображений
images.forEach((image) => {
const imagePath = path.join(__dirname, "public", image.file_path);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
});
}
// Удаляем записи об изображениях из БД
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
});
// Удаляем саму заметку
const deleteSql = "DELETE FROM notes WHERE id = ?";
db.run(deleteSql, id, function (err) {
if (err) {
console.error("Ошибка удаления заметки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Заметка не найдена" });
}
// Логируем удаление заметки
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete",
`Удалена заметка #${id}`,
clientIP
);
res.json({ message: "Заметка удалена" });
});
});
});
});
// API для загрузки изображений к заметке
app.post(
"/api/notes/:id/images",
requireAuth,
uploadNoteImages.array("images", 10), // Максимум 10 изображений
(req, res) => {
const { id } = req.params;
const files = req.files;
if (!files || files.length === 0) {
return res.status(400).json({ error: "Файлы не загружены" });
}
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
// Сохраняем информацию об изображениях в БД
const insertSql = `
INSERT INTO note_images (note_id, filename, original_name, file_path, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?)
`;
const uploadedImages = [];
let completed = 0;
files.forEach((file) => {
const filePath = "/uploads/" + file.filename;
const params = [
id,
file.filename,
file.originalname,
filePath,
file.size,
file.mimetype,
];
db.run(insertSql, params, function (err) {
if (err) {
console.error("Ошибка сохранения изображения:", err.message);
// Удаляем файл, если не удалось сохранить в БД
const imagePath = path.join(__dirname, "public", filePath);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
} else {
uploadedImages.push({
id: this.lastID,
filename: file.filename,
original_name: file.originalname,
file_path: filePath,
file_size: file.size,
mime_type: file.mimetype,
});
}
completed++;
if (completed === files.length) {
if (uploadedImages.length === 0) {
return res
.status(500)
.json({ error: "Не удалось загрузить изображения" });
}
res.json({
success: true,
message: `Загружено ${uploadedImages.length} изображений`,
images: uploadedImages,
});
}
});
});
});
}
);
// API для получения изображений заметки
app.get("/api/notes/:id/images", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
// Получаем изображения заметки
const getImagesSql = `
SELECT id, filename, original_name, file_path, file_size, mime_type, created_at
FROM note_images
WHERE note_id = ?
ORDER BY created_at ASC
`;
db.all(getImagesSql, [id], (err, images) => {
if (err) {
console.error("Ошибка получения изображений:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json(images);
});
});
});
// API для удаления изображения заметки
app.delete("/api/notes/:noteId/images/:imageId", requireAuth, (req, res) => {
const { noteId, imageId } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [noteId], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
// Получаем информацию об изображении
const getImageSql =
"SELECT file_path FROM note_images WHERE id = ? AND note_id = ?";
db.get(getImageSql, [imageId, noteId], (err, image) => {
if (err) {
console.error("Ошибка получения изображения:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!image) {
return res.status(404).json({ error: "Изображение не найдено" });
}
// Удаляем файл изображения
const imagePath = path.join(__dirname, "public", image.file_path);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
// Удаляем запись из БД
const deleteSql = "DELETE FROM note_images WHERE id = ? AND note_id = ?";
db.run(deleteSql, [imageId, noteId], function (err) {
if (err) {
console.error("Ошибка удаления изображения:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Изображение не найдено" });
}
res.json({ success: true, message: "Изображение удалено" });
});
});
});
});
// Страница личного кабинета
app.get("/profile", requireAuth, (req, res) => {
// Получаем цвет пользователя для предотвращения FOUC
const sql = "SELECT accent_color FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, user) => {
if (err) {
console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "profile.html"));
}
const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл
fs.readFile(
path.join(__dirname, "public", "profile.html"),
"utf8",
(err, html) => {
if (err) {
console.error("Ошибка чтения файла profile.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "profile.html"));
}
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
const inlineCSS = `<style>
:root, html { --accent-color: ${accentColor} !important; }
* { --accent-color: ${accentColor} !important; }
</style>`;
const modifiedHtml = html.replace(
/<head>/i,
`<head>\n ${inlineCSS}`
);
res.send(modifiedHtml);
}
);
});
});
// API для обновления профиля
app.put("/api/user/profile", requireAuth, async (req, res) => {
const { username, email, currentPassword, newPassword, accent_color } =
req.body;
const userId = req.session.userId;
try {
// Получаем текущие данные пользователя
const getUserSql = "SELECT * FROM users WHERE id = ?";
db.get(getUserSql, [userId], async (err, user) => {
if (err) {
console.error("Ошибка получения пользователя:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
// Валидация
if (username && username.length < 3) {
return res
.status(400)
.json({ error: "Логин должен быть не менее 3 символов" });
}
// Если пользователь хочет изменить пароль, проверяем текущий пароль
if (newPassword) {
if (!currentPassword) {
return res
.status(400)
.json({ error: "Введите текущий пароль для изменения пароля" });
}
const validPassword = await bcrypt.compare(
currentPassword,
user.password
);
if (!validPassword) {
return res.status(401).json({ error: "Неверный текущий пароль" });
}
if (newPassword.length < 6) {
return res
.status(400)
.json({ error: "Новый пароль должен быть не менее 6 символов" });
}
}
// Формируем SQL запрос для обновления
let updateFields = [];
let params = [];
if (username && username !== user.username) {
updateFields.push("username = ?");
params.push(username);
}
if (email !== undefined) {
updateFields.push("email = ?");
params.push(email || null);
}
if (accent_color !== undefined) {
updateFields.push("accent_color = ?");
params.push(accent_color || "#007bff");
}
if (newPassword) {
const hashedPassword = await bcrypt.hash(newPassword, 10);
updateFields.push("password = ?");
params.push(hashedPassword);
}
if (updateFields.length === 0) {
return res.json({ success: true, message: "Нет изменений" });
}
params.push(userId);
const updateSql = `UPDATE users SET ${updateFields.join(
", "
)} WHERE id = ?`;
db.run(updateSql, params, function (err) {
if (err) {
if (err.message.includes("UNIQUE constraint failed")) {
return res.status(400).json({ error: "Этот логин уже занят" });
}
console.error("Ошибка обновления профиля:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Обновляем имя пользователя в сессии, если оно изменилось
if (username && username !== user.username) {
req.session.username = username;
}
// Логируем обновление профиля
const clientIP = getClientIP(req);
const changes = [];
if (username && username !== user.username) changes.push("логин");
if (email !== undefined) changes.push("email");
if (accent_color !== undefined) changes.push("цвет темы");
if (newPassword) changes.push("пароль");
const details = `Обновлен профиль: ${changes.join(", ")}`;
logAction(userId, "profile_update", details, clientIP);
res.json({ success: true, message: "Профиль успешно обновлен" });
});
});
} catch (err) {
console.error("Ошибка обновления профиля:", err);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// API для загрузки аватарки
app.post(
"/api/user/avatar",
requireAuth,
upload.single("avatar"),
(req, res) => {
if (!req.file) {
return res.status(400).json({ error: "Файл не загружен" });
}
const avatarPath = "/uploads/" + req.file.filename;
const userId = req.session.userId;
// Получаем старую аватарку для удаления
const getOldAvatarSql = "SELECT avatar FROM users WHERE id = ?";
db.get(getOldAvatarSql, [userId], (err, user) => {
if (err) {
console.error("Ошибка получения старой аватарки:", err.message);
}
// Удаляем старую аватарку, если она существует
if (user && user.avatar) {
const oldAvatarPath = path.join(__dirname, "public", user.avatar);
if (fs.existsSync(oldAvatarPath)) {
fs.unlinkSync(oldAvatarPath);
}
}
// Обновляем путь к аватарке в БД
const updateSql = "UPDATE users SET avatar = ? WHERE id = ?";
db.run(updateSql, [avatarPath, userId], function (err) {
if (err) {
console.error("Ошибка обновления аватарки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json({ success: true, avatar: avatarPath });
});
});
}
);
// API для удаления аватарки
app.delete("/api/user/avatar", requireAuth, (req, res) => {
const userId = req.session.userId;
// Получаем текущую аватарку
const getAvatarSql = "SELECT avatar FROM users WHERE id = ?";
db.get(getAvatarSql, [userId], (err, user) => {
if (err) {
console.error("Ошибка получения аватарки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user || !user.avatar) {
return res.status(400).json({ error: "Аватарка не установлена" });
}
// Удаляем файл аватарки
const avatarPath = path.join(__dirname, "public", user.avatar);
if (fs.existsSync(avatarPath)) {
fs.unlinkSync(avatarPath);
}
// Обновляем БД
const updateSql = "UPDATE users SET avatar = NULL WHERE id = ?";
db.run(updateSql, [userId], function (err) {
if (err) {
console.error("Ошибка удаления аватарки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json({ success: true, message: "Аватарка удалена" });
});
});
});
// API для закрепления заметки
app.put("/api/notes/:id/pin", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id, is_pinned FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
const newPinState = row.is_pinned ? 0 : 1;
const updateSql = "UPDATE notes SET is_pinned = ? WHERE id = ?";
db.run(updateSql, [newPinState, id], function (err) {
if (err) {
console.error("Ошибка изменения закрепления:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем действие
const clientIP = getClientIP(req);
const action = newPinState ? "закреплена" : "откреплена";
logAction(
req.session.userId,
"note_pin",
`Заметка #${id} ${action}`,
clientIP
);
res.json({ success: true, is_pinned: newPinState });
});
});
});
// API для архивирования заметки
app.put("/api/notes/:id/archive", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
const updateSql =
"UPDATE notes SET is_archived = 1, is_pinned = 0 WHERE id = ?";
db.run(updateSql, [id], function (err) {
if (err) {
console.error("Ошибка архивирования:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_archive",
`Заметка #${id} архивирована`,
clientIP
);
res.json({ success: true, message: "Заметка архивирована" });
});
});
});
// API для восстановления заметки из архива
app.put("/api/notes/:id/unarchive", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
const updateSql = "UPDATE notes SET is_archived = 0 WHERE id = ?";
db.run(updateSql, [id], function (err) {
if (err) {
console.error("Ошибка восстановления:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_unarchive",
`Заметка #${id} восстановлена из архива`,
clientIP
);
res.json({ success: true, message: "Заметка восстановлена" });
});
});
});
// API для получения архивных заметок
app.get("/api/notes/archived", requireAuth, (req, res) => {
const sql = `
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
'file_path', ni.file_path,
'file_size', ni.file_size,
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
WHERE n.user_id = ? AND n.is_archived = 1
GROUP BY n.id
ORDER BY n.created_at DESC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error("Ошибка получения архивных заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
}));
res.json(notesWithImages);
});
});
// API для окончательного удаления архивной заметки
app.delete("/api/notes/archived/:id", requireAuth, (req, res) => {
const { id } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю и архивирована
const checkSql = "SELECT user_id, is_archived FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => {
if (err) {
console.error("Ошибка проверки доступа:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!row) {
return res.status(404).json({ error: "Заметка не найдена" });
}
if (row.user_id !== req.session.userId) {
return res.status(403).json({ error: "Нет доступа к этой заметке" });
}
if (!row.is_archived) {
return res.status(400).json({ error: "Заметка не архивирована" });
}
// Удаляем изображения заметки
const getImagesSql = "SELECT file_path FROM note_images WHERE note_id = ?";
db.all(getImagesSql, [id], (err, images) => {
if (err) {
console.error("Ошибка получения изображений:", err.message);
} else {
images.forEach((image) => {
const imagePath = path.join(__dirname, "public", image.file_path);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
});
}
// Удаляем записи об изображениях
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
});
// Удаляем саму заметку
const deleteSql = "DELETE FROM notes WHERE id = ?";
db.run(deleteSql, id, function (err) {
if (err) {
console.error("Ошибка удаления заметки:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Заметка #${id} окончательно удалена из архива`,
clientIP
);
res.json({ success: true, message: "Заметка удалена окончательно" });
});
});
});
});
// API для получения логов пользователя
app.get("/api/logs", requireAuth, (req, res) => {
const { action_type, limit = 100, offset = 0 } = req.query;
let sql = `
SELECT id, action_type, details, ip_address, created_at
FROM action_logs
WHERE user_id = ?
`;
const params = [req.session.userId];
// Фильтр по типу действия
if (action_type) {
sql += " AND action_type = ?";
params.push(action_type);
}
sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
params.push(parseInt(limit), parseInt(offset));
db.all(sql, params, (err, rows) => {
if (err) {
console.error("Ошибка получения логов:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json(rows);
});
});
// Страница настроек
app.get("/settings", requireAuth, (req, res) => {
// Получаем цвет пользователя для предотвращения FOUC
const sql = "SELECT accent_color FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, user) => {
if (err) {
console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "settings.html"));
}
const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл
fs.readFile(
path.join(__dirname, "public", "settings.html"),
"utf8",
(err, html) => {
if (err) {
console.error("Ошибка чтения файла settings.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "settings.html"));
}
// Вставляем inline CSS с правильным цветом
const inlineCSS = `<style>
:root, html { --accent-color: ${accentColor} !important; }
* { --accent-color: ${accentColor} !important; }
</style>`;
const modifiedHtml = html.replace(
/<head>/i,
`<head>\n ${inlineCSS}`
);
res.send(modifiedHtml);
}
);
});
});
// Выход
app.post("/logout", (req, res) => {
const userId = req.session.userId;
const clientIP = getClientIP(req);
// Логируем выход
if (userId) {
logAction(userId, "logout", "Выход из системы", clientIP);
}
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Ошибка выхода" });
}
res.redirect("/");
});
});
// Запуск сервера
app.listen(PORT, () => {
console.log(`🚀 Сервер запущен на порту ${PORT}`);
console.log(`📝 Приложение для заметок готово к работе!`);
});