Обновлен файл .gitignore для исключения новых временных и кэшированных файлов, а также добавлены переменные окружения для разработки. Обновлены файлы документации с добавлением информации о ключе шифрования. Внесены изменения в серверный код для поддержки шифрования и дешифрования содержимого заметок. Обновлены компоненты для улучшения работы с заметками и их отображением. Оптимизированы стили и скрипты для улучшения пользовательского интерфейса.

This commit is contained in:
Fovway 2025-11-06 00:00:11 +07:00
parent 82094e46b5
commit e6ebf2cbff
28 changed files with 558 additions and 371 deletions

69
.gitignore vendored
View File

@ -10,17 +10,30 @@ lerna-debug.log*
# Dependencies # Dependencies
node_modules node_modules
backend/node_modules backend/node_modules
.pnp
.pnp.js
# Build outputs # Build outputs
dist dist
dist-ssr dist-ssr
dev-dist
build
*.local *.local
# Vite
.vite
.vite/*
# Environment variables # Environment variables
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.development.local
.env.test.local
.env.production.local
backend/.env backend/.env
backend/.env.local
backend/.env.*.local
# Database files # Database files
backend/database/*.db backend/database/*.db
@ -31,11 +44,29 @@ backend/database/*.db-wal
backend/public/uploads/* backend/public/uploads/*
!backend/public/uploads/.gitkeep !backend/public/uploads/.gitkeep
# Testing
coverage
*.lcov
.nyc_output
.coverage
coverage/
# Cache
.cache
.parcel-cache
.turbo
.eslintcache
.stylelintcache
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
.idea .idea
.DS_Store *.swp
*.swo
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
@ -47,4 +78,40 @@ public/sw.js.map
# OS # OS
Thumbs.db Thumbs.db
Desktop.ini
$RECYCLE.BIN/
.DS_Store
.AppleDouble
.LSOverride
# Temporary files
*.tmp
*.temp
*.bak
*.backup
*~
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Storybook build outputs
storybook-static
# Deployment
.vercel
.netlify
.firebase
.aws-sam
# TypeScript
*.tsbuildinfo
next-env.d.ts

View File

@ -110,8 +110,11 @@ cd ..
PORT=3001 PORT=3001
SESSION_SECRET=your-secret-key-here SESSION_SECRET=your-secret-key-here
NODE_ENV=development NODE_ENV=development
ENCRYPTION_KEY=your-encryption-key-min-32-chars
``` ```
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
--- ---
## 📍 Порты ## 📍 Порты

View File

@ -92,8 +92,11 @@ npm run lint
PORT=3001 PORT=3001
SESSION_SECRET=замените-на-свой-секретный-ключ SESSION_SECRET=замените-на-свой-секретный-ключ
NODE_ENV=development NODE_ENV=development
ENCRYPTION_KEY=замените-на-свой-ключ-минимум-32-символа
``` ```
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
### 2. Первый пользователь ### 2. Первый пользователь
1. Откройте http://localhost:5173 1. Откройте http://localhost:5173

View File

@ -171,8 +171,14 @@ npm run lint
PORT=3001 PORT=3001
SESSION_SECRET=your-secret-key-here SESSION_SECRET=your-secret-key-here
NODE_ENV=development NODE_ENV=development
ENCRYPTION_KEY=your-encryption-key-min-32-chars
``` ```
**Важно:**
- `ENCRYPTION_KEY` - ключ для шифрования заметок в базе данных (минимум 32 символа)
- В продакшене ОБЯЗАТЕЛЬНО используйте надежный случайный ключ
- Не используйте дефолтный ключ в продакшене!
### AI настройки ### AI настройки
Настройки AI выполняются через интерфейс приложения в разделе "Настройки". Настройки AI выполняются через интерфейс приложения в разделе "Настройки".

View File

@ -16,8 +16,14 @@ npm install
PORT=3001 PORT=3001
SESSION_SECRET=your-secret-key-here-change-in-production SESSION_SECRET=your-secret-key-here-change-in-production
NODE_ENV=development NODE_ENV=development
ENCRYPTION_KEY=your-encryption-key-minimum-32-characters-long
``` ```
**Важно:**
- `ENCRYPTION_KEY` - ключ для шифрования заметок в базе данных (минимум 32 символа)
- В продакшене ОБЯЗАТЕЛЬНО используйте надежный случайный ключ
- Потеря ключа приведет к невозможности дешифрования существующих заметок!
## Запуск ## Запуск
### Отдельный запуск бэкенда: ### Отдельный запуск бэкенда:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
try{self["workbox:window:7.2.0"]&&_()}catch{}function E(n,r){return new Promise(function(t){var i=new MessageChannel;i.port1.onmessage=function(c){t(c.data)},n.postMessage(r,[i.port2])})}function W(n){var r=function(t,i){if(typeof t!="object"||!t)return t;var c=t[Symbol.toPrimitive];if(c!==void 0){var h=c.call(t,i);if(typeof h!="object")return h;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(n,"string");return typeof r=="symbol"?r:r+""}function k(n,r){for(var t=0;t<r.length;t++){var i=r[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(n,W(i.key),i)}}function P(n,r){return P=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,i){return t.__proto__=i,t},P(n,r)}function j(n,r){(r==null||r>n.length)&&(r=n.length);for(var t=0,i=new Array(r);t<r;t++)i[t]=n[t];return i}function L(n,r){var t=typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(t)return(t=t.call(n)).next.bind(t);if(Array.isArray(n)||(t=function(c,h){if(c){if(typeof c=="string")return j(c,h);var l=Object.prototype.toString.call(c).slice(8,-1);return l==="Object"&&c.constructor&&(l=c.constructor.name),l==="Map"||l==="Set"?Array.from(c):l==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(l)?j(c,h):void 0}}(n))||r){t&&(n=t);var i=0;return function(){return i>=n.length?{done:!0}:{done:!1,value:n[i++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}try{self["workbox:core:7.2.0"]&&_()}catch{}var w=function(){var n=this;this.promise=new Promise(function(r,t){n.resolve=r,n.reject=t})};function b(n,r){var t=location.href;return new URL(n,t).href===new URL(r,t).href}var g=function(n,r){this.type=n,Object.assign(this,r)};function d(n,r,t){return t?r?r(n):n:(n&&n.then||(n=Promise.resolve(n)),r?n.then(r):n)}function O(){}var x={type:"SKIP_WAITING"};function S(n,r){return n&&n.then?n.then(O):Promise.resolve()}var U=function(n){function r(v,u){var e,o;return u===void 0&&(u={}),(e=n.call(this)||this).nn={},e.tn=0,e.rn=new w,e.en=new w,e.on=new w,e.un=0,e.an=new Set,e.cn=function(){var s=e.fn,a=s.installing;e.tn>0||!b(a.scriptURL,e.sn.toString())||performance.now()>e.un+6e4?(e.vn=a,s.removeEventListener("updatefound",e.cn)):(e.hn=a,e.an.add(a),e.rn.resolve(a)),++e.tn,a.addEventListener("statechange",e.ln)},e.ln=function(s){var a=e.fn,f=s.target,p=f.state,m=f===e.vn,y={sw:f,isExternal:m,originalEvent:s};!m&&e.mn&&(y.isUpdate=!0),e.dispatchEvent(new g(p,y)),p==="installed"?e.wn=self.setTimeout(function(){p==="installed"&&a.waiting===f&&e.dispatchEvent(new g("waiting",y))},200):p==="activating"&&(clearTimeout(e.wn),m||e.en.resolve(f))},e.yn=function(s){var a=e.hn,f=a!==navigator.serviceWorker.controller;e.dispatchEvent(new g("controlling",{isExternal:f,originalEvent:s,sw:a,isUpdate:e.mn})),f||e.on.resolve(a)},e.gn=(o=function(s){var a=s.data,f=s.ports,p=s.source;return d(e.getSW(),function(){e.an.has(p)&&e.dispatchEvent(new g("message",{data:a,originalEvent:s,ports:f,sw:p}))})},function(){for(var s=[],a=0;a<arguments.length;a++)s[a]=arguments[a];try{return Promise.resolve(o.apply(this,s))}catch(f){return Promise.reject(f)}}),e.sn=v,e.nn=u,navigator.serviceWorker.addEventListener("message",e.gn),e}var t,i;i=n,(t=r).prototype=Object.create(i.prototype),t.prototype.constructor=t,P(t,i);var c,h,l=r.prototype;return l.register=function(v){var u=(v===void 0?{}:v).immediate,e=u!==void 0&&u;try{var o=this;return d(function(s,a){var f=s();return f&&f.then?f.then(a):a(f)}(function(){if(!e&&document.readyState!=="complete")return S(new Promise(function(s){return window.addEventListener("load",s)}))},function(){return o.mn=!!navigator.serviceWorker.controller,o.dn=o.pn(),d(o.bn(),function(s){o.fn=s,o.dn&&(o.hn=o.dn,o.en.resolve(o.dn),o.on.resolve(o.dn),o.dn.addEventListener("statechange",o.ln,{once:!0}));var a=o.fn.waiting;return a&&b(a.scriptURL,o.sn.toString())&&(o.hn=a,Promise.resolve().then(function(){o.dispatchEvent(new g("waiting",{sw:a,wasWaitingBeforeRegister:!0}))}).then(function(){})),o.hn&&(o.rn.resolve(o.hn),o.an.add(o.hn)),o.fn.addEventListener("updatefound",o.cn),navigator.serviceWorker.addEventListener("controllerchange",o.yn),o.fn})}))}catch(s){return Promise.reject(s)}},l.update=function(){try{return this.fn?d(S(this.fn.update())):d()}catch(v){return Promise.reject(v)}},l.getSW=function(){return this.hn!==void 0?Promise.resolve(this.hn):this.rn.promise},l.messageSW=function(v){try{return d(this.getSW(),function(u){return E(u,v)})}catch(u){return Promise.reject(u)}},l.messageSkipWaiting=function(){this.fn&&this.fn.waiting&&E(this.fn.waiting,x)},l.pn=function(){var v=navigator.serviceWorker.controller;return v&&b(v.scriptURL,this.sn.toString())?v:void 0},l.bn=function(){try{var v=this;return d(function(u,e){try{var o=u()}catch(s){return e(s)}return o&&o.then?o.then(void 0,e):o}(function(){return d(navigator.serviceWorker.register(v.sn,v.nn),function(u){return v.un=performance.now(),u})},function(u){throw u}))}catch(u){return Promise.reject(u)}},c=r,(h=[{key:"active",get:function(){return this.en.promise}},{key:"controlling",get:function(){return this.on.promise}}])&&k(c.prototype,h),Object.defineProperty(c,"prototype",{writable:!1}),c}(function(){function n(){this.Pn=new Map}var r=n.prototype;return r.addEventListener=function(t,i){this.jn(t).add(i)},r.removeEventListener=function(t,i){this.jn(t).delete(i)},r.dispatchEvent=function(t){t.target=this;for(var i,c=L(this.jn(t.type));!(i=c()).done;)(0,i.value)(t)},r.jn=function(t){return this.Pn.has(t)||this.Pn.set(t,new Set),this.Pn.get(t)},n}());export{U as Workbox,g as WorkboxEvent,E as messageSW};

View File

@ -158,10 +158,78 @@
<!-- Manifest --> <!-- Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-CRKRzJj1.js"></script> <script type="module" crossorigin src="/assets/index-42KwbWCP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QEK5TGz3.css"> <link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
<link rel="manifest" href="/manifest.webmanifest"></head> <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body> <body>
<div id="root"></div> <div id="root">
<!-- Индикатор загрузки до монтирования React -->
<div id="initial-loading" style="
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
">
<style>
#initial-loading-spinner {
width: 50px;
height: 50px;
border: 4px solid transparent;
border-top: 4px solid var(--accent-color, #007bff);
border-right: 4px solid var(--accent-color, #007bff);
border-bottom: 4px solid transparent;
border-left: 4px solid transparent;
border-radius: 50%;
animation: initial-loading-spin 0.8s linear infinite;
opacity: 0.8;
}
@keyframes initial-loading-spin {
to { transform: rotate(360deg); }
}
</style>
<div id="initial-loading-spinner"></div>
</div>
</div>
<script>
// Скрываем индикатор загрузки сразу после загрузки DOM
// React удалит этот элемент при первом рендере через createRoot
(function() {
// Используем MutationObserver для отслеживания изменений в #root
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
// Если React начал добавлять элементы в #root, удаляем индикатор
if (mutation.addedNodes.length > 0) {
const loadingEl = document.getElementById('initial-loading');
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
}
observer.disconnect();
}
});
});
// Начинаем наблюдение за изменениями в #root
const root = document.getElementById('root');
if (root) {
observer.observe(root, { childList: true, subtree: true });
// Фолбэк: если через 2 секунды элемент все еще есть, удаляем вручную
setTimeout(function() {
const loadingEl = document.getElementById('initial-loading');
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
}
observer.disconnect();
}, 2000);
}
})();
</script>
</body> </body>
</html> </html>

View File

@ -9,27 +9,10 @@
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/",
"lang": "ru", "lang": "ru",
"id": "/",
"categories": ["productivity", "utilities"], "categories": ["productivity", "utilities"],
"prefer_related_applications": false, "prefer_related_applications": false,
"display_override": ["window-controls-overlay", "standalone"], "display_override": ["window-controls-overlay", "standalone"],
"dir": "ltr", "dir": "ltr",
"shortcuts": [
{
"name": "Новая заметка",
"short_name": "Новая",
"description": "Создать новую заметку",
"url": "/notes?new=true",
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Мой профиль",
"short_name": "Профиль",
"description": "Открыть профиль",
"url": "/profile",
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
}
],
"screenshots": [ "screenshots": [
{ {
"src": "/icons/icon-512x512.png", "src": "/icons/icon-512x512.png",

View File

@ -1,3 +1,6 @@
// Загружаем .env файл ПЕРВЫМ, до всех остальных импортов
require("dotenv").config();
const express = require("express"); const express = require("express");
const sqlite3 = require("sqlite3").verbose(); const sqlite3 = require("sqlite3").verbose();
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
@ -11,7 +14,7 @@ const multer = require("multer");
const fs = require("fs"); const fs = require("fs");
const https = require("https"); const https = require("https");
const http = require("http"); const http = require("http");
require("dotenv").config(); const { encrypt, decrypt } = require("./utils/encryption");
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@ -659,7 +662,7 @@ app.get("/register", (req, res) => {
if (req.session.authenticated) { if (req.session.authenticated) {
return res.redirect("/notes"); return res.redirect("/notes");
} }
res.sendFile(path.join(__dirname, "public", "register.html")); res.sendFile(path.join(__dirname, "public", "index.html"));
}); });
// API регистрации // API регистрации
@ -815,19 +818,19 @@ app.get("/notes", requireAuth, (req, res) => {
db.get(sql, [req.session.userId], (err, user) => { db.get(sql, [req.session.userId], (err, user) => {
if (err) { if (err) {
console.error("Ошибка получения цвета пользователя:", err.message); console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "notes.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
const accentColor = user?.accent_color || "#007bff"; const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл // Читаем HTML файл
fs.readFile( fs.readFile(
path.join(__dirname, "public", "notes.html"), path.join(__dirname, "public", "index.html"),
"utf8", "utf8",
(err, html) => { (err, html) => {
if (err) { if (err) {
console.error("Ошибка чтения файла notes.html:", err.message); console.error("Ошибка чтения файла index.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "notes.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC // Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
@ -857,11 +860,12 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
let whereClause = "WHERE n.user_id = ? AND n.is_archived = 0"; let whereClause = "WHERE n.user_id = ? AND n.is_archived = 0";
let params = [req.session.userId]; let params = [req.session.userId];
// Поиск по тексту (регистронезависимый) // Поиск по тексту - убран из SQL, так как зашифрованный текст нельзя искать
if (q && q.trim()) { // Поиск будет выполняться на клиенте после дешифрования
whereClause += " AND LOWER(n.content) LIKE ?"; // if (q && q.trim()) {
params.push(`%${q.trim().toLowerCase()}%`); // whereClause += " AND LOWER(n.content) LIKE ?";
} // params.push(`%${q.trim().toLowerCase()}%`);
// }
// Поиск по тегу (регистронезависимый) // Поиск по тегу (регистронезависимый)
// SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте // SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте
@ -932,6 +936,7 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
// Парсим JSON строки изображений и файлов // Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({ const notesWithImagesAndFiles = rows.map((row) => ({
...row, ...row,
content: decrypt(row.content), // Дешифруем содержимое заметки
images: row.images === "[]" ? [] : JSON.parse(row.images), images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files), files: row.files === "[]" ? [] : JSON.parse(row.files),
})); }));
@ -995,6 +1000,7 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
// Парсим JSON строки изображений и файлов // Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({ const notesWithImagesAndFiles = rows.map((row) => ({
...row, ...row,
content: decrypt(row.content), // Дешифруем содержимое заметки
images: row.images === "[]" ? [] : JSON.parse(row.images), images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files), files: row.files === "[]" ? [] : JSON.parse(row.files),
})); }));
@ -1011,9 +1017,12 @@ app.post("/api/notes", requireApiAuth, (req, res) => {
return res.status(400).json({ error: "Не все поля заполнены" }); return res.status(400).json({ error: "Не все поля заполнены" });
} }
// Шифруем содержимое заметки перед сохранением
const encryptedContent = encrypt(content);
const sql = const sql =
"INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; "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]; const params = [req.session.userId, encryptedContent, date, time];
db.run(sql, params, function (err) { db.run(sql, params, function (err) {
if (err) { if (err) {
@ -1038,6 +1047,9 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
return res.status(400).json({ error: "Содержание заметки обязательно" }); return res.status(400).json({ error: "Содержание заметки обязательно" });
} }
// Шифруем содержимое заметки перед сохранением
const encryptedContent = encrypt(content);
// Проверяем, что заметка принадлежит текущему пользователю // Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?"; const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?";
db.get(checkSql, [id], (err, row) => { db.get(checkSql, [id], (err, row) => {
@ -1058,7 +1070,7 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
const updateSql = skipTimestamp const updateSql = skipTimestamp
? "UPDATE notes SET content = ? WHERE id = ?" ? "UPDATE notes SET content = ? WHERE id = ?"
: "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; : "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
const params = [content, id]; const params = [encryptedContent, id];
db.run(updateSql, params, function (err) { db.run(updateSql, params, function (err) {
if (err) { if (err) {
@ -1581,19 +1593,19 @@ app.get("/profile", requireAuth, (req, res) => {
db.get(sql, [req.session.userId], (err, user) => { db.get(sql, [req.session.userId], (err, user) => {
if (err) { if (err) {
console.error("Ошибка получения цвета пользователя:", err.message); console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "profile.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
const accentColor = user?.accent_color || "#007bff"; const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл // Читаем HTML файл
fs.readFile( fs.readFile(
path.join(__dirname, "public", "profile.html"), path.join(__dirname, "public", "index.html"),
"utf8", "utf8",
(err, html) => { (err, html) => {
if (err) { if (err) {
console.error("Ошибка чтения файла profile.html:", err.message); console.error("Ошибка чтения файла index.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "profile.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC // Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
@ -2001,6 +2013,7 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
// Парсим JSON строки изображений и файлов // Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({ const notesWithImagesAndFiles = rows.map((row) => ({
...row, ...row,
content: decrypt(row.content), // Дешифруем содержимое заметки
images: row.images === "[]" ? [] : JSON.parse(row.images), images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files), files: row.files === "[]" ? [] : JSON.parse(row.files),
})); }));
@ -2271,19 +2284,19 @@ app.get("/settings", requireAuth, (req, res) => {
db.get(sql, [req.session.userId], (err, user) => { db.get(sql, [req.session.userId], (err, user) => {
if (err) { if (err) {
console.error("Ошибка получения цвета пользователя:", err.message); console.error("Ошибка получения цвета пользователя:", err.message);
return res.sendFile(path.join(__dirname, "public", "settings.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
const accentColor = user?.accent_color || "#007bff"; const accentColor = user?.accent_color || "#007bff";
// Читаем HTML файл // Читаем HTML файл
fs.readFile( fs.readFile(
path.join(__dirname, "public", "settings.html"), path.join(__dirname, "public", "index.html"),
"utf8", "utf8",
(err, html) => { (err, html) => {
if (err) { if (err) {
console.error("Ошибка чтения файла settings.html:", err.message); console.error("Ошибка чтения файла index.html:", err.message);
return res.sendFile(path.join(__dirname, "public", "settings.html")); return res.sendFile(path.join(__dirname, "public", "index.html"));
} }
// Вставляем inline CSS с правильным цветом // Вставляем inline CSS с правильным цветом

140
backend/utils/encryption.js Normal file
View File

@ -0,0 +1,140 @@
const crypto = require("crypto");
// Ключ шифрования из переменных окружения или дефолтный (для разработки)
// В продакшене ОБЯЗАТЕЛЬНО установите ENCRYPTION_KEY в .env файле!
// Ключ должен быть строкой минимум 32 символа для AES-256
const ENCRYPTION_KEY_STRING =
process.env.ENCRYPTION_KEY || "default-key-change-in-production-min-32-chars";
// Проверка ключа при запуске
const isProduction = process.env.NODE_ENV === "production";
const isUsingDefaultKey = !process.env.ENCRYPTION_KEY;
// В продакшене требуем установленный ключ
if (isProduction && isUsingDefaultKey) {
console.error(
"❌ ОШИБКА: ENCRYPTION_KEY не установлен в переменных окружения!"
);
console.error(
"Установите ENCRYPTION_KEY в .env файле перед запуском в продакшене."
);
console.error("Пример: ENCRYPTION_KEY=ваш-случайный-ключ-минимум-32-символа");
process.exit(1);
}
// Предупреждение в разработке
if (!isProduction && isUsingDefaultKey) {
console.warn("⚠️ ПРЕДУПРЕЖДЕНИЕ: Используется дефолтный ключ шифрования!");
console.warn("Для безопасности установите ENCRYPTION_KEY в .env файле.");
console.warn("Пример: ENCRYPTION_KEY=ваш-случайный-ключ-минимум-32-символа");
}
// Преобразуем строку ключа в 32-байтовый ключ для AES-256
const ENCRYPTION_KEY = crypto
.createHash("sha256")
.update(ENCRYPTION_KEY_STRING)
.digest();
// Длина IV (Initialization Vector) для AES-256-GCM
const IV_LENGTH = 16;
// Метка аутентификации для проверки целостности данных
const AUTH_TAG_LENGTH = 16;
/**
* Шифрует текст заметки
* @param {string} text - Текст для шифрования
* @returns {string} - Зашифрованный текст в формате base64:iv:authTag
*/
function encrypt(text) {
if (!text || text.trim() === "") {
return text;
}
try {
// Генерируем случайный IV для каждой операции шифрования
const iv = crypto.randomBytes(IV_LENGTH);
// Создаем шифр с алгоритмом AES-256-GCM
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
// Шифруем текст
let encrypted = cipher.update(text, "utf8", "base64");
encrypted += cipher.final("base64");
// Получаем метку аутентификации для проверки целостности
const authTag = cipher.getAuthTag();
// Возвращаем зашифрованные данные в формате: base64:iv:authTag
return `${encrypted}:${iv.toString("base64")}:${authTag.toString(
"base64"
)}`;
} catch (error) {
console.error("Ошибка шифрования:", error);
throw new Error("Ошибка шифрования данных");
}
}
/**
* Дешифрует текст заметки
* @param {string} encryptedText - Зашифрованный текст в формате base64:iv:authTag
* @returns {string} - Расшифрованный текст
*/
function decrypt(encryptedText) {
if (!encryptedText || encryptedText.trim() === "") {
return encryptedText;
}
// Проверяем формат зашифрованных данных
// Если формат не соответствует ожидаемому, возможно это старая незашифрованная заметка
const parts = encryptedText.split(":");
if (parts.length !== 3) {
// Если это не зашифрованный текст, возвращаем как есть (для обратной совместимости)
console.warn("Обнаружен незашифрованный текст заметки");
return encryptedText;
}
try {
const [encrypted, ivBase64, authTagBase64] = parts;
// Преобразуем IV и метку аутентификации из base64
const iv = Buffer.from(ivBase64, "base64");
const authTag = Buffer.from(authTagBase64, "base64");
// Создаем дешифратор с алгоритмом AES-256-GCM
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
// Устанавливаем метку аутентификации для проверки целостности
decipher.setAuthTag(authTag);
// Дешифруем текст
let decrypted = decipher.update(encrypted, "base64", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
} catch (error) {
console.error("Ошибка дешифрования:", error);
// Если не удалось дешифровать, возможно это старая заметка
// Возвращаем оригинальный текст
return encryptedText;
}
}
/**
* Проверяет, является ли текст зашифрованным
* @param {string} text - Текст для проверки
* @returns {boolean} - true если текст зашифрован
*/
function isEncrypted(text) {
if (!text || text.trim() === "") {
return false;
}
const parts = text.split(":");
return parts.length === 3;
}
module.exports = {
encrypt,
decrypt,
isEncrypted,
};

View File

@ -82,7 +82,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.hpmojmu6e28" "revision": "0.spl6nc4dqkg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -8,7 +8,7 @@ export const userApi = {
}, },
updateProfile: async ( updateProfile: async (
profile: Partial<User> & { profile: Omit<Partial<User>, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled'> & {
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
accent_color?: string; accent_color?: string;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { import {
format, format,
startOfMonth, startOfMonth,

View File

@ -2,7 +2,6 @@ import React from "react";
import { MiniCalendar } from "../calendar/MiniCalendar"; import { MiniCalendar } from "../calendar/MiniCalendar";
import { SearchBar } from "../search/SearchBar"; import { SearchBar } from "../search/SearchBar";
import { TagsFilter } from "../search/TagsFilter"; import { TagsFilter } from "../search/TagsFilter";
import { useAppSelector } from "../../store/hooks";
import { Note } from "../../types/note"; import { Note } from "../../types/note";
interface SidebarProps { interface SidebarProps {

View File

@ -115,7 +115,14 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
}; };
}, [isDragging]); }, [isDragging]);
const buttons = []; const buttons: Array<{
id: string;
icon: string;
title: string;
before?: string;
after?: string;
action?: () => void;
}> = [];
return ( return (
<div <div

View File

@ -480,14 +480,15 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const lines = text.split("\n"); const lines = text.split("\n");
// Определяем текущую строку // Определяем текущую строку
let currentLineIndex = 0; // @ts-expect-error - переменная используется для определения текущей строки
let _currentLineIndex = 0;
let currentLineStart = 0; let currentLineStart = 0;
let currentLine = ""; let currentLine = "";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length; const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) { if (currentLineStart + lineLength >= start) {
currentLineIndex = i; _currentLineIndex = i;
currentLine = lines[i]; currentLine = lines[i];
break; break;
} }
@ -611,7 +612,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const lineHeight = parseInt(styles.lineHeight) || 20; const lineHeight = parseInt(styles.lineHeight) || 20;
const paddingTop = parseInt(styles.paddingTop) || 0; const paddingTop = parseInt(styles.paddingTop) || 0;
const paddingLeft = parseInt(styles.paddingLeft) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0;
const fontSize = parseInt(styles.fontSize) || 14; // @ts-expect-error - переменная может использоваться в будущем
const _fontSize = parseInt(styles.fontSize) || 14;
const scrollTop = textarea.scrollTop; const scrollTop = textarea.scrollTop;
// Вычисляем координаты начала выделения // Вычисляем координаты начала выделения

View File

@ -31,7 +31,7 @@ interface NoteItemProps {
export const NoteItem: React.FC<NoteItemProps> = ({ export const NoteItem: React.FC<NoteItemProps> = ({
note, note,
onDelete, onDelete: _onDelete,
onPin, onPin,
onArchive, onArchive,
onReload, onReload,
@ -41,8 +41,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [showArchiveModal, setShowArchiveModal] = useState(false); const [showArchiveModal, setShowArchiveModal] = useState(false);
const [editImages, setEditImages] = useState<File[]>([]); const [editImages, setEditImages] = useState<File[]>([]);
const [editFiles, setEditFiles] = useState<File[]>([]); const [editFiles, setEditFiles] = useState<File[]>([]);
const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]); const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]); const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false); const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
@ -146,19 +146,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
setLocalPreviewMode(false); setLocalPreviewMode(false);
}; };
const handleDeleteExistingImage = (imageId: number) => { const handleDeleteExistingImage = (imageId: number | string) => {
setDeletedImageIds([...deletedImageIds, imageId]); setDeletedImageIds([...deletedImageIds, imageId]);
}; };
const handleDeleteExistingFile = (fileId: number) => { const handleDeleteExistingFile = (fileId: number | string) => {
setDeletedFileIds([...deletedFileIds, fileId]); setDeletedFileIds([...deletedFileIds, fileId]);
}; };
const handleRestoreImage = (imageId: number) => { const handleRestoreImage = (imageId: number | string) => {
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId)); setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
}; };
const handleRestoreFile = (fileId: number) => { const handleRestoreFile = (fileId: number | string) => {
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId)); setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
}; };
@ -576,7 +576,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const lineHeight = parseInt(styles.lineHeight) || 20; const lineHeight = parseInt(styles.lineHeight) || 20;
const paddingTop = parseInt(styles.paddingTop) || 0; const paddingTop = parseInt(styles.paddingTop) || 0;
const paddingLeft = parseInt(styles.paddingLeft) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0;
const fontSize = parseInt(styles.fontSize) || 14; // @ts-expect-error - переменная может использоваться в будущем
const _fontSize = parseInt(styles.fontSize) || 14;
// Более точный расчет ширины символа // Более точный расчет ширины символа
// Создаем временный элемент для измерения // Создаем временный элемент для измерения
@ -699,14 +700,15 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const lines = text.split("\n"); const lines = text.split("\n");
// Определяем текущую строку // Определяем текущую строку
let currentLineIndex = 0; // @ts-expect-error - переменная используется для определения текущей строки
let _currentLineIndex = 0;
let currentLineStart = 0; let currentLineStart = 0;
let currentLine = ""; let currentLine = "";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length; const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) { if (currentLineStart + lineLength >= start) {
currentLineIndex = i; _currentLineIndex = i;
currentLine = lines[i]; currentLine = lines[i];
break; break;
} }
@ -1259,8 +1261,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
.map((image) => { .map((image) => {
const imageUrl = getImageUrl( const imageUrl = getImageUrl(
image.file_path, image.file_path,
note.id, Number(note.id),
image.id Number(image.id)
); );
return ( return (
<div key={image.id} className="image-preview-item"> <div key={image.id} className="image-preview-item">
@ -1298,8 +1300,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
.map((image) => { .map((image) => {
const imageUrl = getImageUrl( const imageUrl = getImageUrl(
image.file_path, image.file_path,
note.id, Number(note.id),
image.id Number(image.id)
); );
return ( return (
<div key={image.id} className="image-preview-item"> <div key={image.id} className="image-preview-item">
@ -1336,11 +1338,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.files {note.files
.filter((file) => !deletedFileIds.includes(file.id)) .filter((file) => !deletedFileIds.includes(file.id))
.map((file) => { .map((file) => {
const fileUrl = getFileUrl( // fileUrl не используется в этом контексте
file.file_path,
note.id,
file.id
);
return ( return (
<div key={file.id} className="file-preview-item"> <div key={file.id} className="file-preview-item">
<Icon <Icon
@ -1476,8 +1474,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.images.map((image) => { {note.images.map((image) => {
const imageUrl = getImageUrl( const imageUrl = getImageUrl(
image.file_path, image.file_path,
note.id, Number(note.id),
image.id Number(image.id)
); );
return ( return (
<div key={image.id} className="note-image-item"> <div key={image.id} className="note-image-item">
@ -1499,7 +1497,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.files && note.files.length > 0 && ( {note.files && note.files.length > 0 && (
<div className="note-files-container"> <div className="note-files-container">
{note.files.map((file) => { {note.files.map((file) => {
const fileUrl = getFileUrl(file.file_path, note.id, file.id); const fileUrl = getFileUrl(file.file_path, Number(note.id), Number(file.id));
return ( return (
<div key={file.id} className="note-file-item"> <div key={file.id} className="note-file-item">
<a <a

View File

@ -1,4 +1,4 @@
import React, { useEffect, useImperativeHandle, forwardRef } from "react"; import { useEffect, useImperativeHandle, forwardRef } from "react";
import { NoteItem } from "./NoteItem"; import { NoteItem } from "./NoteItem";
import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { useAppSelector, useAppDispatch } from "../../store/hooks";
import { offlineNotesApi } from "../../api/offlineNotesApi"; import { offlineNotesApi } from "../../api/offlineNotesApi";
@ -10,7 +10,7 @@ export interface NotesListRef {
reloadNotes: () => void; reloadNotes: () => void;
} }
export const NotesList = forwardRef<NotesListRef>((props, ref) => { export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
const notes = useAppSelector((state) => state.notes.notes); const notes = useAppSelector((state) => state.notes.notes);
const userId = useAppSelector((state) => state.auth.userId); const userId = useAppSelector((state) => state.auth.userId);
const searchQuery = useAppSelector((state) => state.notes.searchQuery); const searchQuery = useAppSelector((state) => state.notes.searchQuery);

View File

@ -6,7 +6,7 @@ import { setSearchQuery } from "../../store/slices/notesSlice";
export const SearchBar: React.FC = () => { export const SearchBar: React.FC = () => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
// Debounce для поиска // Debounce для поиска

View File

@ -10,7 +10,10 @@ export const useNotification = () => {
message: string, message: string,
type: "info" | "success" | "error" | "warning" = "info" type: "info" | "success" | "error" | "warning" = "info"
) => { ) => {
const id = dispatch(addNotification({ message, type })).payload.id; const id = `notification-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
dispatch(addNotification({ message, type, id }));
setTimeout(() => { setTimeout(() => {
dispatch(removeNotification(id)); dispatch(removeNotification(id));
}, 4000); }, 4000);

View File

@ -17,7 +17,8 @@ const ProfilePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const user = useAppSelector((state) => state.profile.user); // @ts-expect-error - переменная может использоваться в будущем
const _user = useAppSelector((state) => state.profile.user);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");

View File

@ -13,7 +13,6 @@ import { setAccentColor } from "../utils/colorUtils";
import { useNotification } from "../hooks/useNotification"; import { useNotification } from "../hooks/useNotification";
import { Modal } from "../components/common/Modal"; import { Modal } from "../components/common/Modal";
import { ThemeToggle } from "../components/common/ThemeToggle"; import { ThemeToggle } from "../components/common/ThemeToggle";
import { formatDateFromTimestamp } from "../utils/dateFormat";
import { parseMarkdown } from "../utils/markdown"; import { parseMarkdown } from "../utils/markdown";
import { dbManager } from "../utils/indexedDB"; import { dbManager } from "../utils/indexedDB";
import { syncService } from "../services/syncService"; import { syncService } from "../services/syncService";
@ -25,8 +24,11 @@ const SettingsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const user = useAppSelector((state) => state.profile.user); // @ts-expect-error - переменная может использоваться в будущем
const accentColor = useAppSelector((state) => state.ui.accentColor); const _user = useAppSelector((state) => state.profile.user);
const userId = useAppSelector((state) => state.auth.userId);
// @ts-expect-error - переменная может использоваться в будущем
const _accentColor = useAppSelector((state) => state.ui.accentColor);
const [activeTab, setActiveTab] = useState<SettingsTab>(() => { const [activeTab, setActiveTab] = useState<SettingsTab>(() => {
// Восстанавливаем активную вкладку из localStorage при инициализации // Восстанавливаем активную вкладку из localStorage при инициализации
@ -280,9 +282,9 @@ const SettingsPage: React.FC = () => {
} }
}; };
const handleRestoreNote = async (id: number) => { const handleRestoreNote = async (id: number | string) => {
try { try {
await notesApi.unarchive(id); await notesApi.unarchive(Number(id));
await loadArchivedNotes(); await loadArchivedNotes();
showNotification("Заметка восстановлена!", "success"); showNotification("Заметка восстановлена!", "success");
} catch (error: any) { } catch (error: any) {
@ -294,9 +296,9 @@ const SettingsPage: React.FC = () => {
} }
}; };
const handleDeletePermanent = async (id: number) => { const handleDeletePermanent = async (id: number | string) => {
try { try {
await notesApi.deleteArchived(id); await notesApi.deleteArchived(Number(id));
await loadArchivedNotes(); await loadArchivedNotes();
showNotification("Заметка удалена окончательно", "success"); showNotification("Заметка удалена окончательно", "success");
} catch (error: any) { } catch (error: any) {
@ -427,7 +429,6 @@ const SettingsPage: React.FC = () => {
// Загружаем версию из IndexedDB // Загружаем версию из IndexedDB
try { try {
const userId = user?.id;
const localVer = userId const localVer = userId
? await dbManager.getDataVersionByUserId(userId) ? await dbManager.getDataVersionByUserId(userId)
: await dbManager.getDataVersion(); : await dbManager.getDataVersion();
@ -872,14 +873,14 @@ const SettingsPage: React.FC = () => {
<div className="archived-note-actions"> <div className="archived-note-actions">
<button <button
className="btn-restore" className="btn-restore"
onClick={() => handleRestoreNote(note.id)} onClick={() => handleRestoreNote(Number(note.id))}
title="Восстановить" title="Восстановить"
> >
<Icon icon="mdi:restore" /> Восстановить <Icon icon="mdi:restore" /> Восстановить
</button> </button>
<button <button
className="btn-delete-permanent" className="btn-delete-permanent"
onClick={() => handleDeletePermanent(note.id)} onClick={() => handleDeletePermanent(Number(note.id))}
title="Удалить навсегда" title="Удалить навсегда"
> >
<Icon icon="mdi:delete-forever" /> Удалить <Icon icon="mdi:delete-forever" /> Удалить

View File

@ -5,13 +5,11 @@ import { Note, NoteImage, NoteFile } from '../types/note';
import { store } from '../store/index'; import { store } from '../store/index';
import { import {
setSyncStatus, setSyncStatus,
removeNotification,
addNotification, addNotification,
} from '../store/slices/uiSlice'; } from '../store/slices/uiSlice';
import { import {
updateNote, updateNote,
setPendingSyncCount, setPendingSyncCount,
setOfflineMode,
} from '../store/slices/notesSlice'; } from '../store/slices/notesSlice';
import { SyncQueueItem } from '../types/note'; import { SyncQueueItem } from '../types/note';
@ -20,7 +18,7 @@ const RETRY_DELAY_MS = 5000;
class SyncService { class SyncService {
private isSyncing = false; private isSyncing = false;
private syncTimer: NodeJS.Timeout | null = null; private syncTimer: ReturnType<typeof setTimeout> | null = null;
private listeners: Array<() => void> = []; private listeners: Array<() => void> = [];
/** /**
@ -444,7 +442,7 @@ class SyncService {
*/ */
private async updateImageReferences( private async updateImageReferences(
localNote: Note, localNote: Note,
serverNote: Note _serverNote: Note
): Promise<NoteImage[]> { ): Promise<NoteImage[]> {
// Если нет изображений с base64, возвращаем как есть // Если нет изображений с base64, возвращаем как есть
const hasBase64Images = localNote.images.some((img) => img.base64Data); const hasBase64Images = localNote.images.some((img) => img.base64Data);
@ -461,7 +459,7 @@ class SyncService {
*/ */
private async updateFileReferences( private async updateFileReferences(
localNote: Note, localNote: Note,
serverNote: Note _serverNote: Note
): Promise<NoteFile[]> { ): Promise<NoteFile[]> {
// Если нет файлов с base64, возвращаем как есть // Если нет файлов с base64, возвращаем как есть
const hasBase64Files = localNote.files.some((file) => file.base64Data); const hasBase64Files = localNote.files.some((file) => file.base64Data);

View File

@ -50,12 +50,13 @@ const uiSlice = createSlice({
}, },
addNotification: ( addNotification: (
state, state,
action: PayloadAction<Omit<Notification, "id">> action: PayloadAction<Omit<Notification, "id"> & { id?: string }>
) => { ) => {
const id = `notification-${Date.now()}-${Math.random() const id = action.payload.id || `notification-${Date.now()}-${Math.random()
.toString(36) .toString(36)
.substr(2, 9)}`; .substr(2, 9)}`;
state.notifications.push({ ...action.payload, id }); const { id: _, ...notificationData } = action.payload;
state.notifications.push({ ...notificationData, id });
}, },
removeNotification: (state, action: PayloadAction<string>) => { removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter( state.notifications = state.notifications.filter(