// Загружаем .env файл ПЕРВЫМ, до всех остальных импортов require("dotenv").config(); 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"); const { encrypt, decrypt } = require("./utils/encryption"); const app = express(); const PORT = process.env.PORT || 3001; // Настройка 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) разрешены!")); }, }); // Настройка multer для загрузки файлов заметок const noteFileStorage = 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-file-" + req.session.userId + "-" + uniqueSuffix + path.extname(file.originalname) ); }, }); const uploadNoteFiles = multer({ storage: noteFileStorage, limits: { fileSize: 50 * 1024 * 1024 }, // 50MB максимум для файлов заметок fileFilter: function (req, file, cb) { // Разрешенные типы файлов const allowedMimes = [ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "text/plain", "application/zip", "application/x-zip-compressed", "application/x-rar-compressed", "application/x-7z-compressed", ]; const allowedExtensions = /pdf|doc|docx|xls|xlsx|txt|zip|rar|7z/; const extname = allowedExtensions.test( path.extname(file.originalname).toLowerCase() ); if (allowedMimes.includes(file.mimetype) || extname) { return cb(null, true); } cb( new Error( "Только файлы форматов pdf, doc, docx, xls, xlsx, txt, zip, rar, 7z разрешены!" ) ); }, }); // 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 createNoteFilesTable = ` CREATE TABLE IF NOT EXISTS note_files ( 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(createNoteFilesTable, (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_note_files_note_id ON note_files(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) { const sql = ` INSERT INTO action_logs (user_id, action_type, details, ip_address) VALUES (?, ?, ?, NULL) `; db.run(sql, [userId, actionType, details], (err) => { if (err) { console.error("Ошибка логирования действия:", err.message); } }); } // Миграции базы данных 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"); } }); } // Проверяем существование колонки show_edit_date const hasShowEditDate = columns.some( (col) => col.name === "show_edit_date" ); if (!hasShowEditDate) { db.run( "ALTER TABLE users ADD COLUMN show_edit_date INTEGER DEFAULT 1", (err) => { if (err) { console.error( "Ошибка добавления колонки show_edit_date:", err.message ); } else { console.log("Колонка show_edit_date добавлена в таблицу users"); } } ); } // Проверяем существование колонки colored_icons const hasColoredIcons = columns.some((col) => col.name === "colored_icons"); if (!hasColoredIcons) { db.run( "ALTER TABLE users ADD COLUMN colored_icons INTEGER DEFAULT 1", (err) => { if (err) { console.error( "Ошибка добавления колонки colored_icons:", err.message ); } else { console.log("Колонка colored_icons добавлена в таблицу users"); } } ); } // Проверяем существование колонки floating_toolbar_enabled const hasFloatingToolbarEnabled = columns.some( (col) => col.name === "floating_toolbar_enabled" ); if (!hasFloatingToolbarEnabled) { db.run( "ALTER TABLE users ADD COLUMN floating_toolbar_enabled INTEGER DEFAULT 1", (err) => { if (err) { console.error( "Ошибка добавления колонки floating_toolbar_enabled:", err.message ); } else { console.log( "Колонка floating_toolbar_enabled добавлена в таблицу 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", "index.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; // Логируем регистрацию logAction(this.lastID, "register", `Регистрация нового пользователя`); 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; // Логируем вход logAction(user.id, "login", `Вход в систему`); 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, show_edit_date, colored_icons, floating_toolbar_enabled 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", "index.html")); } const accentColor = user?.accent_color || "#007bff"; // Читаем HTML файл fs.readFile( path.join(__dirname, "public", "index.html"), "utf8", (err, html) => { if (err) { console.error("Ошибка чтения файла index.html:", err.message); return res.sendFile(path.join(__dirname, "public", "index.html")); } // Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC const inlineCSS = ``; const modifiedHtml = html.replace( /
/i, `\n ${inlineCSS}` ); res.send(modifiedHtml); } ); }); }); // API для поиска заметок с изображениями (должен быть ПЕРЕД /api/notes/:id) app.get("/api/notes/search", requireApiAuth, (req, res) => { if (!req.session.userId) { return res.status(401).json({ error: "Не аутентифицирован" }); } const { q, tag, date } = req.query; let whereClause = "WHERE n.user_id = ? AND n.is_archived = 0"; let params = [req.session.userId]; // Поиск по тексту - убран из SQL, так как зашифрованный текст нельзя искать // Поиск будет выполняться на клиенте после дешифрования // if (q && q.trim()) { // whereClause += " AND LOWER(n.content) LIKE ?"; // params.push(`%${q.trim().toLowerCase()}%`); // } // Поиск по тегу (регистронезависимый) // SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте // Здесь не фильтруем по тегу, чтобы получить все заметки для последующей фильтрации на клиенте // Если есть другие фильтры (дата, поиск), они применяются здесь // if (tag && 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(DISTINCT ni.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT 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 ) ) FILTER (WHERE ni.id IS NOT NULL) END as images, CASE WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT json_object( 'id', nf.id, 'filename', nf.filename, 'original_name', nf.original_name, 'file_path', nf.file_path, 'file_size', nf.file_size, 'mime_type', nf.mime_type, 'created_at', nf.created_at ) ) FILTER (WHERE nf.id IS NOT NULL) END as files FROM notes n LEFT JOIN note_images ni ON n.id = ni.note_id LEFT JOIN note_files nf ON n.id = nf.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: "Ошибка сервера" }); } // Логируем для отладки console.log( `Поиск заметок: userId=${req.session.userId}, date=${ date || "none" }, найдено: ${rows.length}` ); // Парсим JSON строки изображений и файлов const notesWithImagesAndFiles = rows.map((row) => ({ ...row, content: decrypt(row.content), // Дешифруем содержимое заметки images: row.images === "[]" ? [] : JSON.parse(row.images), files: row.files === "[]" ? [] : JSON.parse(row.files), })); // Отключаем кэширование ответов res.set("Cache-Control", "no-store, no-cache, must-revalidate, private"); res.set("Pragma", "no-cache"); res.set("Expires", "0"); res.json(notesWithImagesAndFiles); }); }); // API для получения всех заметок с изображениями и файлами (исключая архивные) app.get("/api/notes", requireApiAuth, (req, res) => { const sql = ` SELECT n.*, CASE WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT 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 ) ) FILTER (WHERE ni.id IS NOT NULL) END as images, CASE WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT json_object( 'id', nf.id, 'filename', nf.filename, 'original_name', nf.original_name, 'file_path', nf.file_path, 'file_size', nf.file_size, 'mime_type', nf.mime_type, 'created_at', nf.created_at ) ) FILTER (WHERE nf.id IS NOT NULL) END as files FROM notes n LEFT JOIN note_images ni ON n.id = ni.note_id LEFT JOIN note_files nf ON n.id = nf.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 notesWithImagesAndFiles = rows.map((row) => ({ ...row, content: decrypt(row.content), // Дешифруем содержимое заметки images: row.images === "[]" ? [] : JSON.parse(row.images), files: row.files === "[]" ? [] : JSON.parse(row.files), })); res.json(notesWithImagesAndFiles); }); }); // 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 encryptedContent = encrypt(content); const sql = "INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; const params = [req.session.userId, encryptedContent, date, time]; db.run(sql, params, function (err) { if (err) { console.error("Ошибка создания заметки:", err.message); return res.status(500).json({ error: "Ошибка сервера" }); } // Логируем создание заметки const noteId = this.lastID; logAction(req.session.userId, "note_create", `Создана заметка #${noteId}`); res.json({ id: noteId, content, date, time }); }); }); // API для обновления заметки app.put("/api/notes/:id", requireApiAuth, (req, res) => { const { content, skipTimestamp } = req.body; const { id } = req.params; if (!content) { return res.status(400).json({ error: "Содержание заметки обязательно" }); } // Шифруем содержимое заметки перед сохранением const encryptedContent = encrypt(content); // Проверяем, что заметка принадлежит текущему пользователю 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: "Нет доступа к этой заметке" }); } // Если skipTimestamp=true (обновление чекбокса), не обновляем updated_at const updateSql = skipTimestamp ? "UPDATE notes SET content = ? WHERE id = ?" : "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; const params = [encryptedContent, 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: "Заметка не найдена" }); } // Логируем обновление заметки только если это не обновление чекбокса if (!skipTimestamp) { logAction( req.session.userId, "note_update", `Обновлена заметка #${id}` ); } res.json({ id, content, date: row.date, time: row.time }); }); }); }); // API для получения версии данных (последний updated_at) app.get("/api/notes/version", requireApiAuth, (req, res) => { const sql = ` SELECT MAX(updated_at) as last_updated_at, MAX(created_at) as last_created_at, COUNT(*) as total_notes FROM notes WHERE user_id = ? AND is_archived = 0 `; db.get(sql, [req.session.userId], (err, row) => { if (err) { console.error("Ошибка получения версии данных:", err.message); return res.status(500).json({ error: "Ошибка сервера" }); } res.json({ last_updated_at: row?.last_updated_at || null, last_created_at: row?.last_created_at || null, total_notes: row?.total_notes || 0, timestamp: Date.now(), }); }); }); // 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 getFilesSql = "SELECT file_path FROM note_files WHERE note_id = ?"; db.all(getFilesSql, [id], (err, files) => { if (err) { console.error("Ошибка получения файлов:", err.message); } else { // Удаляем файлы files.forEach((file) => { const filePath = path.join(__dirname, "public", file.file_path); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } }); } // Удаляем записи об изображениях из БД const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?"; db.run(deleteImagesSql, [id], (err) => { if (err) { console.error("Ошибка удаления изображений:", err.message); } }); // Удаляем записи о файлах из БД const deleteFilesSql = "DELETE FROM note_files WHERE note_id = ?"; db.run(deleteFilesSql, [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: "Заметка не найдена" }); } // Логируем удаление заметки logAction( req.session.userId, "note_delete", `Удалена заметка #${id}` ); 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; // Ensure proper UTF-8 encoding for original filename const originalName = Buffer.from(file.originalname, "latin1").toString( "utf8" ); const params = [ id, file.filename, 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: "Изображение удалено" }); }); }); }); }); // API для загрузки файлов к заметке app.post( "/api/notes/:id/files", requireAuth, uploadNoteFiles.array("files", 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_files (note_id, filename, original_name, file_path, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?) `; const uploadedFiles = []; let completed = 0; files.forEach((file) => { const filePath = "/uploads/" + file.filename; // Ensure proper UTF-8 encoding for original filename const originalName = Buffer.from(file.originalname, "latin1").toString( "utf8" ); const params = [ id, file.filename, originalName, filePath, file.size, file.mimetype, ]; db.run(insertSql, params, function (err) { if (err) { console.error("Ошибка сохранения файла:", err.message); // Удаляем файл, если не удалось сохранить в БД const fileFullPath = path.join(__dirname, "public", filePath); if (fs.existsSync(fileFullPath)) { fs.unlinkSync(fileFullPath); } } else { uploadedFiles.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 (uploadedFiles.length === 0) { return res .status(500) .json({ error: "Не удалось загрузить файлы" }); } res.json({ success: true, message: `Загружено ${uploadedFiles.length} файлов`, files: uploadedFiles, }); } }); }); }); } ); // API для получения файлов заметки app.get("/api/notes/:id/files", 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 getFilesSql = ` SELECT id, filename, original_name, file_path, file_size, mime_type, created_at FROM note_files WHERE note_id = ? ORDER BY created_at ASC `; db.all(getFilesSql, [id], (err, files) => { if (err) { console.error("Ошибка получения файлов:", err.message); return res.status(500).json({ error: "Ошибка сервера" }); } res.json(files); }); }); }); // API для удаления файла заметки app.delete("/api/notes/:noteId/files/:fileId", requireApiAuth, (req, res) => { const { noteId, fileId } = 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 getFileSql = "SELECT file_path FROM note_files WHERE id = ? AND note_id = ?"; db.get(getFileSql, [fileId, noteId], (err, file) => { if (err) { console.error("Ошибка получения файла:", err.message); return res.status(500).json({ error: "Ошибка сервера" }); } if (!file) { return res.status(404).json({ error: "Файл не найден" }); } // Удаляем файл const filePath = path.join(__dirname, "public", file.file_path); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Удаляем запись из БД const deleteSql = "DELETE FROM note_files WHERE id = ? AND note_id = ?"; db.run(deleteSql, [fileId, 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", "index.html")); } const accentColor = user?.accent_color || "#007bff"; // Читаем HTML файл fs.readFile( path.join(__dirname, "public", "index.html"), "utf8", (err, html) => { if (err) { console.error("Ошибка чтения файла index.html:", err.message); return res.sendFile(path.join(__dirname, "public", "index.html")); } // Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC const inlineCSS = ``; const modifiedHtml = html.replace( //i, `\n ${inlineCSS}` ); res.send(modifiedHtml); } ); }); }); // API для обновления профиля app.put("/api/user/profile", requireApiAuth, async (req, res) => { const { username, email, currentPassword, newPassword, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled, } = 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 (show_edit_date !== undefined) { updateFields.push("show_edit_date = ?"); params.push(show_edit_date ? 1 : 0); } if (colored_icons !== undefined) { updateFields.push("colored_icons = ?"); params.push(colored_icons ? 1 : 0); } if (floating_toolbar_enabled !== undefined) { updateFields.push("floating_toolbar_enabled = ?"); params.push(floating_toolbar_enabled ? 1 : 0); } 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 changes = []; if (username && username !== user.username) changes.push("логин"); if (email !== undefined) changes.push("email"); if (accent_color !== undefined) changes.push("цвет темы"); if (show_edit_date !== undefined) changes.push("настройка показа даты редактирования"); if (colored_icons !== undefined) changes.push("цветные иконки"); if (newPassword) changes.push("пароль"); const details = `Обновлен профиль: ${changes.join(", ")}`; logAction(userId, "profile_update", details); 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 action = newPinState ? "закреплена" : "откреплена"; logAction(req.session.userId, "note_pin", `Заметка #${id} ${action}`); 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: "Ошибка сервера" }); } // Логируем действие logAction( req.session.userId, "note_archive", `Заметка #${id} архивирована` ); 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: "Ошибка сервера" }); } // Логируем действие logAction( req.session.userId, "note_unarchive", `Заметка #${id} восстановлена из архива` ); res.json({ success: true, message: "Заметка восстановлена" }); }); }); }); // API для получения архивных заметок app.get("/api/notes/archived", requireApiAuth, (req, res) => { const sql = ` SELECT n.*, CASE WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT 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 ) ) FILTER (WHERE ni.id IS NOT NULL) END as images, CASE WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]' ELSE json_group_array( DISTINCT json_object( 'id', nf.id, 'filename', nf.filename, 'original_name', nf.original_name, 'file_path', nf.file_path, 'file_size', nf.file_size, 'mime_type', nf.mime_type, 'created_at', nf.created_at ) ) FILTER (WHERE nf.id IS NOT NULL) END as files FROM notes n LEFT JOIN note_images ni ON n.id = ni.note_id LEFT JOIN note_files nf ON n.id = nf.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 notesWithImagesAndFiles = rows.map((row) => ({ ...row, content: decrypt(row.content), // Дешифруем содержимое заметки images: row.images === "[]" ? [] : JSON.parse(row.images), files: row.files === "[]" ? [] : JSON.parse(row.files), })); res.json(notesWithImagesAndFiles); }); }); // 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 getFilesSql = `SELECT file_path FROM note_files WHERE note_id IN (${placeholders})`; db.all(getFilesSql, noteIds, (err, files) => { if (err) { console.error("Ошибка получения файлов:", err.message); } else { // Удаляем файлы files.forEach((file) => { const filePath = path.join(__dirname, "public", file.file_path); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } 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 deleteFilesSql = `DELETE FROM note_files WHERE note_id IN (${placeholders})`; db.run(deleteFilesSql, 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; // Логируем действие logAction( req.session.userId, "note_delete_permanent", `Удалены все архивные заметки (${deletedCount} шт.)` ); 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 getFilesSql = "SELECT file_path FROM note_files WHERE note_id = ?"; db.all(getFilesSql, [id], (err, files) => { if (err) { console.error("Ошибка получения файлов:", err.message); } else { files.forEach((file) => { const filePath = path.join(__dirname, "public", file.file_path); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } }); } // Удаляем записи об изображениях const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?"; db.run(deleteImagesSql, [id], (err) => { if (err) { console.error("Ошибка удаления изображений:", err.message); } }); // Удаляем записи о файлах const deleteFilesSql = "DELETE FROM note_files WHERE note_id = ?"; db.run(deleteFilesSql, [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: "Ошибка сервера" }); } // Логируем действие logAction( req.session.userId, "note_delete_permanent", `Заметка #${id} окончательно удалена из архива` ); 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, 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", "index.html")); } const accentColor = user?.accent_color || "#007bff"; // Читаем HTML файл fs.readFile( path.join(__dirname, "public", "index.html"), "utf8", (err, html) => { if (err) { console.error("Ошибка чтения файла index.html:", err.message); return res.sendFile(path.join(__dirname, "public", "index.html")); } // Вставляем inline CSS с правильным цветом const inlineCSS = ``; const modifiedHtml = html.replace( //i, `\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 настроек logAction(userId, "profile_update", "Изменен статус AI"); 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 настроек logAction(userId, "profile_update", "Обновлены AI настройки"); 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 logAction(req.session.userId, "ai_improve", "Улучшен текст через AI"); 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; // Логируем выход if (userId) { logAction(userId, "logout", "Выход из системы"); } req.session.destroy((err) => { if (err) { return res.status(500).json({ error: "Ошибка выхода" }); } res.redirect("/"); }); }); // API для выхода (для фронтенда) app.post("/api/logout", (req, res) => { const userId = req.session.userId; // Логируем выход if (userId) { logAction(userId, "logout", "Выход из системы"); } req.session.destroy((err) => { if (err) { return res.status(500).json({ error: "Ошибка выхода" }); } res.json({ success: true, message: "Выход выполнен успешно" }); }); }); // 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: "Ошибка получения аватарки" }); } // Получаем все изображения заметок пользователя const getImagesSql = ` SELECT ni.file_path FROM note_images ni JOIN notes n ON ni.note_id = n.id WHERE n.user_id = ? `; db.all(getImagesSql, [userId], (err, images) => { if (err) { console.error( "Ошибка получения изображений пользователя:", err.message ); } // Получаем все файлы заметок пользователя const getFilesSql = ` SELECT nf.file_path FROM note_files nf JOIN notes n ON nf.note_id = n.id WHERE n.user_id = ? `; db.all(getFilesSql, [userId], (err, files) => { if (err) { console.error( "Ошибка получения файлов пользователя:", err.message ); } // Удаляем файл аватарки if (userData && userData.avatar) { const avatarPath = path.join( __dirname, "public", userData.avatar ); if (fs.existsSync(avatarPath)) { try { fs.unlinkSync(avatarPath); } catch (err) { console.error("Ошибка удаления файла аватарки:", err.message); } } } // Удаляем все изображения if (images) { 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.message); } } }); } // Удаляем все файлы if (files) { files.forEach((file) => { const filePath = path.join(__dirname, "public", file.file_path); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (err) { console.error("Ошибка удаления файла:", err.message); } } }); } // Начинаем транзакцию для удаления всех данных из БД db.serialize(() => { db.run("BEGIN TRANSACTION"); // Удаляем все изображения заметок пользователя 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: "Ошибка удаления изображений" }); } }); // Удаляем все файлы заметок пользователя const deleteFilesSql = ` DELETE FROM note_files WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?) `; db.run(deleteFilesSql, [userId], (err) => { if (err) { console.error("Ошибка удаления файлов из БД:", err.message); db.run("ROLLBACK"); return res .status(500) .json({ error: "Ошибка удаления файлов" }); } }); // Удаляем все заметки пользователя 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(`📝 Приложение для заметок готово к работе!`); });