Обновлен файл .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
node_modules
backend/node_modules
.pnp
.pnp.js
# Build outputs
dist
dist-ssr
dev-dist
build
*.local
# Vite
.vite
.vite/*
# Environment variables
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local
backend/.env
backend/.env.local
backend/.env.*.local
# Database files
backend/database/*.db
@ -31,11 +44,29 @@ backend/database/*.db-wal
backend/public/uploads/*
!backend/public/uploads/.gitkeep
# Testing
coverage
*.lcov
.nyc_output
.coverage
coverage/
# Cache
.cache
.parcel-cache
.turbo
.eslintcache
.stylelintcache
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
.idea
.DS_Store
*.swp
*.swo
*.suo
*.ntvs*
*.njsproj
@ -47,4 +78,40 @@ public/sw.js.map
# OS
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
SESSION_SECRET=your-secret-key-here
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
SESSION_SECRET=замените-на-свой-секретный-ключ
NODE_ENV=development
ENCRYPTION_KEY=замените-на-свой-ключ-минимум-32-символа
```
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
### 2. Первый пользователь
1. Откройте http://localhost:5173

View File

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

View File

@ -16,8 +16,14 @@ npm install
PORT=3001
SESSION_SECRET=your-secret-key-here-change-in-production
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 -->
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-CRKRzJj1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QEK5TGz3.css">
<link rel="manifest" href="/manifest.webmanifest"></head>
<script type="module" crossorigin src="/assets/index-42KwbWCP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<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>
</html>

View File

@ -9,27 +9,10 @@
"orientation": "portrait-primary",
"scope": "/",
"lang": "ru",
"id": "/",
"categories": ["productivity", "utilities"],
"prefer_related_applications": false,
"display_override": ["window-controls-overlay", "standalone"],
"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": [
{
"src": "/icons/icon-512x512.png",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@ interface NoteItemProps {
export const NoteItem: React.FC<NoteItemProps> = ({
note,
onDelete,
onDelete: _onDelete,
onPin,
onArchive,
onReload,
@ -41,8 +41,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [showArchiveModal, setShowArchiveModal] = useState(false);
const [editImages, setEditImages] = useState<File[]>([]);
const [editFiles, setEditFiles] = useState<File[]>([]);
const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]);
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
@ -146,19 +146,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
setLocalPreviewMode(false);
};
const handleDeleteExistingImage = (imageId: number) => {
const handleDeleteExistingImage = (imageId: number | string) => {
setDeletedImageIds([...deletedImageIds, imageId]);
};
const handleDeleteExistingFile = (fileId: number) => {
const handleDeleteExistingFile = (fileId: number | string) => {
setDeletedFileIds([...deletedFileIds, fileId]);
};
const handleRestoreImage = (imageId: number) => {
const handleRestoreImage = (imageId: number | string) => {
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
};
const handleRestoreFile = (fileId: number) => {
const handleRestoreFile = (fileId: number | string) => {
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
};
@ -576,7 +576,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const lineHeight = parseInt(styles.lineHeight) || 20;
const paddingTop = parseInt(styles.paddingTop) || 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");
// Определяем текущую строку
let currentLineIndex = 0;
// @ts-expect-error - переменная используется для определения текущей строки
let _currentLineIndex = 0;
let currentLineStart = 0;
let currentLine = "";
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) {
currentLineIndex = i;
_currentLineIndex = i;
currentLine = lines[i];
break;
}
@ -1259,8 +1261,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
Number(note.id),
Number(image.id)
);
return (
<div key={image.id} className="image-preview-item">
@ -1298,8 +1300,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
Number(note.id),
Number(image.id)
);
return (
<div key={image.id} className="image-preview-item">
@ -1336,11 +1338,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.files
.filter((file) => !deletedFileIds.includes(file.id))
.map((file) => {
const fileUrl = getFileUrl(
file.file_path,
note.id,
file.id
);
// fileUrl не используется в этом контексте
return (
<div key={file.id} className="file-preview-item">
<Icon
@ -1476,8 +1474,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.images.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
Number(note.id),
Number(image.id)
);
return (
<div key={image.id} className="note-image-item">
@ -1499,7 +1497,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.files && note.files.length > 0 && (
<div className="note-files-container">
{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 (
<div key={file.id} className="note-file-item">
<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 { useAppSelector, useAppDispatch } from "../../store/hooks";
import { offlineNotesApi } from "../../api/offlineNotesApi";
@ -10,7 +10,7 @@ export interface NotesListRef {
reloadNotes: () => void;
}
export const NotesList = forwardRef<NotesListRef>((props, ref) => {
export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
const notes = useAppSelector((state) => state.notes.notes);
const userId = useAppSelector((state) => state.auth.userId);
const searchQuery = useAppSelector((state) => state.notes.searchQuery);

View File

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

View File

@ -10,7 +10,10 @@ export const useNotification = () => {
message: string,
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(() => {
dispatch(removeNotification(id));
}, 4000);

View File

@ -17,7 +17,8 @@ const ProfilePage: React.FC = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
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 [email, setEmail] = useState("");

View File

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

View File

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

View File

@ -50,12 +50,13 @@ const uiSlice = createSlice({
},
addNotification: (
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)
.substr(2, 9)}`;
state.notifications.push({ ...action.payload, id });
const { id: _, ...notificationData } = action.payload;
state.notifications.push({ ...notificationData, id });
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(