- Удалена функция получения IP-адреса клиента, так как она больше не используется. - Обновлена функция логирования действий пользователя для исключения параметра IP-адреса. - Обновлены все вызовы логирования в коде, чтобы соответствовать новым изменениям. - Удалены ссылки на IP-адрес в интерфейсе отображения логов действий.
2634 lines
93 KiB
JavaScript
2634 lines
93 KiB
JavaScript
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) разрешены!"));
|
||
},
|
||
});
|
||
|
||
// Настройка 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");
|
||
}
|
||
});
|
||
}
|
||
|
||
// Проверяем существование колонки 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;
|
||
|
||
// Логируем регистрацию
|
||
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 FROM users WHERE id = ?";
|
||
db.get(sql, [req.session.userId], (err, user) => {
|
||
if (err) {
|
||
console.error("Ошибка получения данных пользователя:", err.message);
|
||
return res.status(500).json({ error: "Ошибка сервера" });
|
||
}
|
||
|
||
if (!user) {
|
||
return res.status(404).json({ error: "Пользователь не найден" });
|
||
}
|
||
|
||
res.json(user);
|
||
});
|
||
});
|
||
|
||
// Страница с заметками (требует аутентификации)
|
||
app.get("/notes", requireAuth, (req, res) => {
|
||
// Получаем цвет пользователя для предотвращения FOUC
|
||
const sql = "SELECT accent_color FROM users WHERE id = ?";
|
||
db.get(sql, [req.session.userId], (err, user) => {
|
||
if (err) {
|
||
console.error("Ошибка получения цвета пользователя:", err.message);
|
||
return res.sendFile(path.join(__dirname, "public", "notes.html"));
|
||
}
|
||
|
||
const accentColor = user?.accent_color || "#007bff";
|
||
|
||
// Читаем HTML файл
|
||
fs.readFile(
|
||
path.join(__dirname, "public", "notes.html"),
|
||
"utf8",
|
||
(err, html) => {
|
||
if (err) {
|
||
console.error("Ошибка чтения файла notes.html:", err.message);
|
||
return res.sendFile(path.join(__dirname, "public", "notes.html"));
|
||
}
|
||
|
||
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
||
const inlineCSS = `<style>
|
||
:root, html { --accent-color: ${accentColor} !important; }
|
||
* { --accent-color: ${accentColor} !important; }
|
||
</style>`;
|
||
const modifiedHtml = html.replace(
|
||
/<head>/i,
|
||
`<head>\n ${inlineCSS}`
|
||
);
|
||
|
||
res.send(modifiedHtml);
|
||
}
|
||
);
|
||
});
|
||
});
|
||
|
||
// API для поиска заметок с изображениями (должен быть ПЕРЕД /api/notes/:id)
|
||
app.get("/api/notes/search", 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(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: "Ошибка сервера" });
|
||
}
|
||
|
||
// Парсим JSON строки изображений и файлов
|
||
const notesWithImagesAndFiles = rows.map((row) => ({
|
||
...row,
|
||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||
}));
|
||
|
||
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,
|
||
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 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 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 } = 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: "Заметка не найдена" });
|
||
}
|
||
|
||
// Логируем обновление заметки
|
||
logAction(req.session.userId, "note_update", `Обновлена заметка #${id}`);
|
||
|
||
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 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;
|
||
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: "Изображение удалено" });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// 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;
|
||
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 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", "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 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);
|
||
|
||
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,
|
||
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", "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 настроек
|
||
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.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(`📝 Приложение для заметок готово к работе!`);
|
||
});
|