NoteJS/server.js
Fovway 372cea2e92 Добавлена поддержка спойлеров в редактор заметок и улучшены индексы базы данных
- Реализована функция вставки спойлеров в режиме редактирования заметок.
- Обновлены стили для кнопок markdown в редакторе.
- Добавлен новый индекс для колонки `pinned_at` в таблице `notes`.
- Обновлены SQL-запросы для сортировки заметок с учетом нового поля `pinned_at`.
2025-10-28 21:37:21 +07:00

2327 lines
82 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");
const https = require("https");
const http = require("http");
require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 3000;
// Настройка trust proxy для nginx proxy manager
// Доверяем всем прокси (nginx proxy manager должен передавать X-Forwarded-For)
app.set("trust proxy", true);
// Создаем директорию для аватарок, если её нет
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_notes_pinned_at ON notes(pinned_at)",
"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) {
// Логируем все заголовки для отладки
console.log("=== IP Headers Debug ===");
console.log("req.ip:", req.ip);
console.log("x-forwarded-for:", req.headers["x-forwarded-for"]);
console.log("x-real-ip:", req.headers["x-real-ip"]);
console.log("x-client-ip:", req.headers["x-client-ip"]);
console.log("cf-connecting-ip:", req.headers["cf-connecting-ip"]); // Cloudflare
console.log("x-forwarded:", req.headers["x-forwarded"]);
console.log("forwarded:", req.headers["forwarded"]);
console.log("req.connection.remoteAddress:", req.connection?.remoteAddress);
console.log("req.socket.remoteAddress:", req.socket?.remoteAddress);
console.log("ALL HEADERS:", JSON.stringify(req.headers, null, 2));
console.log("========================");
// Приоритет для получения реального IP клиента:
// 1. req.socket.remoteAddress (для случаев с дополнительными прокси)
// 2. X-Real-IP (часто используется nginx)
// 3. X-Forwarded-For (стандартный заголовок)
// 4. CF-Connecting-IP (если используется Cloudflare)
// 5. X-Client-IP
// 6. req.ip (Express с trust proxy)
let ip = null;
// Проверяем socket_remoteAddress в первую очередь (для случаев с дополнительными прокси)
if (
req.socket?.remoteAddress &&
req.socket.remoteAddress !== "::1" &&
req.socket.remoteAddress !== "127.0.0.1"
) {
ip = req.socket.remoteAddress;
}
// Проверяем X-Real-IP (nginx часто использует этот заголовок)
else if (req.headers["x-real-ip"]) {
ip = req.headers["x-real-ip"];
}
// Проверяем X-Forwarded-For (берем первый IP из списка)
else if (req.headers["x-forwarded-for"]) {
ip = req.headers["x-forwarded-for"].split(",")[0].trim();
}
// Проверяем CF-Connecting-IP (Cloudflare)
else if (req.headers["cf-connecting-ip"]) {
ip = req.headers["cf-connecting-ip"];
}
// Проверяем X-Client-IP
else if (req.headers["x-client-ip"]) {
ip = req.headers["x-client-ip"];
}
// Проверяем Forwarded заголовок (RFC 7239)
else if (req.headers["forwarded"]) {
const forwarded = req.headers["forwarded"];
const forMatch = forwarded.match(/for=([^;,\s]+)/);
if (forMatch) {
ip = forMatch[1];
}
}
// Используем req.ip (Express с trust proxy)
else if (req.ip && req.ip !== "::1" && req.ip !== "127.0.0.1") {
ip = req.ip;
}
// Очищаем IP от скобок IPv6, портов и кавычек
if (ip && ip !== "unknown") {
// Убираем скобки IPv6
ip = ip.replace(/[[\]]/g, "");
// Убираем кавычки если есть
ip = ip.replace(/['"]/g, "");
// Обрабатываем IPv6 адреса
if (ip.startsWith("::ffff:")) {
// IPv4-mapped IPv6 address (::ffff:192.168.1.1)
ip = ip.substring(7); // Убираем "::ffff:"
} else if (ip.includes("::")) {
const ipv6Match = ip.match(/^(\[)?([^\]]+)(\])?(:(\d+))?$/);
if (ipv6Match) {
ip = ipv6Match[2];
}
} else if (ip.includes(":")) {
// IPv4 с портом
const parts = ip.split(":");
if (parts.length === 2 && /^\d+$/.test(parts[1])) {
ip = parts[0];
}
}
}
// Конвертируем IPv6 localhost в IPv4
if (ip === "::1" || ip === "::ffff:127.0.0.1") {
ip = "127.0.0.1";
}
return ip || "unknown";
}
// Миграции базы данных
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");
}
}
);
}
// Проверяем существование колонок для AI настроек
const hasOpenaiApiKey = columns.some(
(col) => col.name === "openai_api_key"
);
const hasOpenaiBaseUrl = columns.some(
(col) => col.name === "openai_base_url"
);
const hasOpenaiModel = columns.some((col) => col.name === "openai_model");
if (!hasOpenaiApiKey) {
db.run("ALTER TABLE users ADD COLUMN openai_api_key TEXT", (err) => {
if (err) {
console.error(
"Ошибка добавления колонки openai_api_key:",
err.message
);
} else {
console.log("Колонка openai_api_key добавлена в таблицу users");
}
});
}
if (!hasOpenaiBaseUrl) {
db.run("ALTER TABLE users ADD COLUMN openai_base_url TEXT", (err) => {
if (err) {
console.error(
"Ошибка добавления колонки openai_base_url:",
err.message
);
} else {
console.log("Колонка openai_base_url добавлена в таблицу users");
}
});
}
if (!hasOpenaiModel) {
db.run("ALTER TABLE users ADD COLUMN openai_model TEXT", (err) => {
if (err) {
console.error("Ошибка добавления колонки openai_model:", err.message);
} else {
console.log("Колонка openai_model добавлена в таблицу users");
}
});
}
// Проверяем существование колонки ai_enabled
const hasAiEnabled = columns.some((col) => col.name === "ai_enabled");
if (!hasAiEnabled) {
db.run(
"ALTER TABLE users ADD COLUMN ai_enabled INTEGER DEFAULT 0",
(err) => {
if (err) {
console.error("Ошибка добавления колонки ai_enabled:", err.message);
} else {
console.log("Колонка ai_enabled добавлена в таблицу 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");
const hasPinnedAt = columns.some((col) => col.name === "pinned_at");
// Добавляем 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");
}
}
);
}
// Добавляем pinned_at если нужно
if (!hasPinnedAt) {
db.run("ALTER TABLE notes ADD COLUMN pinned_at DATETIME", (err) => {
if (err) {
console.error("Ошибка добавления колонки pinned_at:", err.message);
} else {
console.log("Колонка pinned_at добавлена в таблицу 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("/");
}
}
// Middleware для аутентификации API (возвращает JSON вместо редиректа)
function requireApiAuth(req, res, next) {
if (req.session.authenticated) {
return next();
} else {
return res.status(401).json({ error: "Не аутентифицирован" });
}
}
// Маршруты
// Главная страница с формой входа
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", requireApiAuth, (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);
});
});
// Тестовый эндпоинт для проверки IP-адресов (только для отладки)
app.get("/api/debug/ip", (req, res) => {
const clientIP = getClientIP(req);
res.json({
detected_ip: clientIP,
headers: {
"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"],
"x-forwarded": req.headers["x-forwarded"],
forwarded: req.headers["forwarded"],
},
express: {
req_ip: req.ip,
socket_remoteAddress: req.socket?.remoteAddress,
connection_remoteAddress: req.connection?.remoteAddress,
},
all_headers: req.headers,
});
});
// Страница с заметками (требует аутентификации)
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", requireApiAuth, (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.is_pinned DESC, n.pinned_at DESC, 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", requireApiAuth, (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.pinned_at 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", requireApiAuth, (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", requireApiAuth, (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", requireApiAuth, (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", requireApiAuth, (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", requireApiAuth, (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", requireApiAuth, 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", requireApiAuth, (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", requireApiAuth, (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 = newPinState
? "UPDATE notes SET is_pinned = 1, pinned_at = CURRENT_TIMESTAMP WHERE id = ?"
: "UPDATE notes SET is_pinned = 0, pinned_at = NULL WHERE id = ?";
db.run(updateSql, [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", requireApiAuth, (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, pinned_at = NULL 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", requireApiAuth, (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", requireApiAuth, (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.is_pinned DESC, n.pinned_at 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.delete("/api/notes/archived/all", requireApiAuth, async (req, res) => {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: "Пароль обязателен" });
}
try {
// Получаем хеш пароля пользователя
const userSql = "SELECT password FROM users WHERE id = ?";
db.get(userSql, [req.session.userId], async (err, user) => {
if (err) {
console.error("Ошибка получения пользователя:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
// Проверяем пароль
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: "Неверный пароль" });
}
// Получаем все архивные заметки пользователя
const getNotesSql =
"SELECT id FROM notes WHERE user_id = ? AND is_archived = 1";
db.all(getNotesSql, [req.session.userId], (err, notes) => {
if (err) {
console.error("Ошибка получения архивных заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (notes.length === 0) {
return res.json({
success: true,
message: "Архив уже пуст",
deletedCount: 0,
});
}
// Удаляем изображения для всех заметок
const noteIds = notes.map((note) => note.id);
const placeholders = noteIds.map(() => "?").join(",");
const getImagesSql = `SELECT file_path FROM note_images WHERE note_id IN (${placeholders})`;
db.all(getImagesSql, noteIds, (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)) {
try {
fs.unlinkSync(imagePath);
} catch (err) {
console.error("Ошибка удаления файла изображения:", err);
}
}
});
}
// Удаляем записи об изображениях
const deleteImagesSql = `DELETE FROM note_images WHERE note_id IN (${placeholders})`;
db.run(deleteImagesSql, noteIds, (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
// Удаляем сами заметки
const deleteNotesSql =
"DELETE FROM notes WHERE user_id = ? AND is_archived = 1";
db.run(deleteNotesSql, [req.session.userId], function (err) {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
const deletedCount = this.changes;
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Удалены все архивные заметки (${deletedCount} шт.)`,
clientIP
);
res.json({
success: true,
message: `Удалено ${deletedCount} архивных заметок`,
deletedCount,
});
});
});
});
});
});
} catch (error) {
console.error("Ошибка при проверке пароля:", error);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// API для окончательного удаления архивной заметки
app.delete("/api/notes/archived/:id", requireApiAuth, (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", requireApiAuth, (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);
}
);
});
});
// API для получения AI настроек
app.get("/api/user/ai-settings", requireApiAuth, (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Не аутентифицирован" });
}
const sql =
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!settings) {
return res.status(404).json({ error: "Настройки не найдены" });
}
res.json({
openai_api_key: settings.openai_api_key || "",
openai_base_url: settings.openai_base_url || "",
openai_model: settings.openai_model || "",
ai_enabled: settings.ai_enabled || 0,
});
});
});
// API для сохранения AI настроек
app.put("/api/user/ai-settings", requireApiAuth, (req, res) => {
const { openai_api_key, openai_base_url, openai_model, ai_enabled } =
req.body;
const userId = req.session.userId;
// ai_enabled может быть передан отдельно от остальных настроек
if (
ai_enabled !== undefined &&
!openai_api_key &&
!openai_base_url &&
!openai_model
) {
const updateSql = "UPDATE users SET ai_enabled = ? WHERE id = ?";
db.run(updateSql, [ai_enabled ? 1 : 0, userId], function (err) {
if (err) {
console.error("Ошибка сохранения статуса AI:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем обновление AI настроек
const clientIP = getClientIP(req);
logAction(userId, "profile_update", "Изменен статус AI", clientIP);
res.json({ success: true, message: "Настройки AI успешно сохранены" });
});
} else {
// Если сохраняем полные настройки
if (!openai_api_key || !openai_base_url || !openai_model) {
return res.status(400).json({ error: "Все поля обязательны" });
}
// При сохранении полных настроек также проверяем ai_enabled
// Если все поля заполнены и ai_enabled не передан, оставляем текущее значение
// Если передан ai_enabled, используем его
const finalAiEnabled =
ai_enabled !== undefined ? (ai_enabled ? 1 : 0) : undefined;
let updateSql, updateParams;
if (finalAiEnabled !== undefined) {
updateSql =
"UPDATE users SET openai_api_key = ?, openai_base_url = ?, openai_model = ?, ai_enabled = ? WHERE id = ?";
updateParams = [
openai_api_key,
openai_base_url,
openai_model,
finalAiEnabled,
userId,
];
} else {
updateSql =
"UPDATE users SET openai_api_key = ?, openai_base_url = ?, openai_model = ? WHERE id = ?";
updateParams = [openai_api_key, openai_base_url, openai_model, userId];
}
db.run(updateSql, updateParams, function (err) {
if (err) {
console.error("Ошибка сохранения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем обновление AI настроек
const clientIP = getClientIP(req);
logAction(userId, "profile_update", "Обновлены AI настройки", clientIP);
res.json({ success: true, message: "AI настройки успешно сохранены" });
});
}
});
// API для улучшения текста через AI
app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: "Текст обязателен" });
}
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (
!settings ||
!settings.openai_api_key ||
!settings.openai_base_url ||
!settings.openai_model
) {
return res
.status(400)
.json({ error: "Настройте AI настройки в параметрах" });
}
try {
// Парсим URL
const url = new URL(settings.openai_base_url);
const isHttps = url.protocol === "https:";
const hostname = url.hostname;
const port = url.port || (isHttps ? 443 : 80);
// Формируем путь, избегая дублирования
let path = url.pathname || "";
if (!path.endsWith("/chat/completions")) {
path = path.endsWith("/")
? path + "chat/completions"
: path + "/chat/completions";
}
// Подготавливаем данные для запроса
const requestData = JSON.stringify({
model: settings.openai_model,
messages: [
{
role: "system",
content:
"Ты универсальный помощник для работы с текстом. Твоя задача:\n1. Если пользователь просит что-то придумать или сгенерировать (например, поздравление, письмо, пост) - создай качественный текст на заданную тему\n2. Если пользователь предоставил готовый текст - улучши его: исправь ошибки, улучши стиль и грамотность, сделай текст более понятным и выразительным, сохрани основную мысль\n3. Если пользователь просит создать список, используй markdown формат:\n - Для маркированных списков используй: - или *\n - Для нумерованных: 1. 2. 3.\n - Для заголовков: # ## ###\n4. Используй markdown форматирование (жирный **текст**, курсив *текст*, списки, заголовки) когда это уместно\n5. Обращай внимание на контекст и намерение пользователя\nВерни только готовый текст без дополнительных пояснений.",
},
{
role: "user",
content: text,
},
],
temperature: 0.5,
max_tokens: 4000,
});
// Выполняем HTTP запрос
const improvedText = await new Promise((resolve, reject) => {
const options = {
hostname: hostname,
port: port,
path: path,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.openai_api_key}`,
"Content-Length": Buffer.byteLength(requestData),
},
};
const client = isHttps ? https : http;
const req = client.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const responseData = JSON.parse(data);
const improvedText =
responseData.choices[0]?.message?.content || text;
resolve(improvedText);
} catch (err) {
console.error("Ошибка парсинга ответа:", err);
reject(new Error("Ошибка обработки ответа от AI"));
}
} else {
console.error("Ошибка OpenAI API:", res.statusCode, data);
reject(
new Error(
`Ошибка OpenAI API: ${res.statusCode} - ${data.substring(
0,
100
)}`
)
);
}
});
});
req.on("error", (error) => {
console.error("Ошибка запроса к OpenAI:", error);
reject(new Error("Ошибка подключения к OpenAI API"));
});
req.write(requestData);
req.end();
});
// Логируем использование AI
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"ai_improve",
"Улучшен текст через AI",
clientIP
);
res.json({ success: true, improvedText });
} catch (error) {
console.error("Ошибка вызова OpenAI API:", error);
return res
.status(500)
.json({ error: error.message || "Ошибка подключения к OpenAI API" });
}
});
} catch (error) {
console.error("Ошибка улучшения текста:", error);
res.status(500).json({ error: "Ошибка улучшения текста" });
}
});
// Выход
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("/");
});
});
// API для удаления аккаунта
app.delete("/api/user/delete-account", requireApiAuth, async (req, res) => {
const { password } = req.body;
const userId = req.session.userId;
if (!password) {
return res.status(400).json({ error: "Пароль обязателен" });
}
try {
// Получаем пользователя и проверяем пароль
const getUserSql = "SELECT id, password 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: "Пользователь не найден" });
}
// Проверяем пароль
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({ error: "Неверный пароль" });
}
// Получаем аватарку пользователя перед удалением
const getAvatarSql = "SELECT avatar FROM users WHERE id = ?";
db.get(getAvatarSql, [userId], (err, userData) => {
if (err) {
console.error("Ошибка получения аватарки:", err.message);
return res.status(500).json({ error: "Ошибка получения аватарки" });
}
// Удаляем файл аватарки, если он существует
if (userData && userData.avatar) {
const avatarPath = path.join(
__dirname,
"public",
"uploads",
userData.avatar
);
fs.unlink(avatarPath, (err) => {
if (err && err.code !== "ENOENT") {
console.error("Ошибка удаления файла аватарки:", err.message);
}
});
}
// Начинаем транзакцию для удаления всех данных пользователя
db.serialize(() => {
db.run("BEGIN TRANSACTION");
// Удаляем все изображения заметок пользователя (через JOIN с notes)
const deleteImagesSql = `
DELETE FROM note_images
WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)
`;
db.run(deleteImagesSql, [userId], (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления изображений" });
}
});
// Удаляем все заметки пользователя (CASCADE удалит связанные изображения)
db.run("DELETE FROM notes WHERE user_id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
db.run("ROLLBACK");
return res.status(500).json({ error: "Ошибка удаления заметок" });
}
});
// Удаляем логи пользователя
db.run(
"DELETE FROM action_logs WHERE user_id = ?",
[userId],
(err) => {
if (err) {
console.error("Ошибка удаления логов:", err.message);
db.run("ROLLBACK");
return res.status(500).json({ error: "Ошибка удаления логов" });
}
}
);
// Удаляем пользователя из базы данных
db.run("DELETE FROM users WHERE id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления пользователя:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления пользователя" });
}
// Подтверждаем транзакцию
db.run("COMMIT", (err) => {
if (err) {
console.error("Ошибка подтверждения транзакции:", err.message);
return res
.status(500)
.json({ error: "Ошибка подтверждения транзакции" });
}
// Уничтожаем сессию
req.session.destroy((err) => {
if (err) {
console.error("Ошибка уничтожения сессии:", err.message);
}
res.json({
message: "Аккаунт успешно удален",
success: true,
});
});
});
});
});
});
});
} catch (error) {
console.error("Ошибка удаления аккаунта:", error);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// Запуск сервера
app.listen(PORT, () => {
console.log(`🚀 Сервер запущен на порту ${PORT}`);
console.log(`📝 Приложение для заметок готово к работе!`);
});