Compare commits

..

No commits in common. "7d115e18455615663ed56eb08f13331999da430f" and "12c0870c8fafcdd8983ac9c97ad0abc08d5a253b" have entirely different histories.

7 changed files with 21 additions and 98 deletions

View File

@ -8,7 +8,6 @@ const session = require("express-session");
const SQLiteStore = require("connect-sqlite3")(session); const SQLiteStore = require("connect-sqlite3")(session);
const path = require("path"); const path = require("path");
const helmet = require("helmet"); const helmet = require("helmet");
const cors = require("cors");
const rateLimit = require("express-rate-limit"); const rateLimit = require("express-rate-limit");
const bodyParser = require("body-parser"); const bodyParser = require("body-parser");
const multer = require("multer"); const multer = require("multer");
@ -26,38 +25,6 @@ const PORT = process.env.PORT || 3001;
// Доверяем всем прокси (nginx proxy manager должен передавать X-Forwarded-For) // Доверяем всем прокси (nginx proxy manager должен передавать X-Forwarded-For)
app.set("trust proxy", true); app.set("trust proxy", true);
// Конфигурация CORS (защита от CSRF)
const corsOptions = {
origin: function (origin, callback) {
// Список разрешённых доменов
const allowedOrigins = [
"http://localhost:3000",
"http://localhost:5173", // Vite dev server
"http://localhost:5174",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
];
// В продакшене добавить домены вашего сайта
if (process.env.ALLOWED_ORIGINS) {
allowedOrigins.push(...process.env.ALLOWED_ORIGINS.split(","));
}
// Если origin не указан (например, для мобильного приложения), разрешаем
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("CORS не разрешен для этого домена"));
}
},
credentials: true, // разрешить куки
optionsSuccessStatus: 200,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
};
app.use(cors(corsOptions));
// Создаем директорию для аватарок, если её нет // Создаем директорию для аватарок, если её нет
const uploadsDir = path.join(__dirname, "public", "uploads"); const uploadsDir = path.join(__dirname, "public", "uploads");
if (!fs.existsSync(uploadsDir)) { if (!fs.existsSync(uploadsDir)) {
@ -218,42 +185,12 @@ app.use(
}) })
); );
// Ограничение запросов (включено для защиты от DDoS и брутфорса) // Ограничение запросов (отключено для разработки)
const limiter = rateLimit({ // const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут // windowMs: 15 * 60 * 1000, // 15 минут
max: 1000, // максимум 1000 запросов с одного IP // max: 100, // максимум 100 запросов с одного IP
standardHeaders: true, // вернёт информацию о rate limit в `RateLimit-*` заголовки // });
legacyHeaders: false, // отключить `X-RateLimit-*` заголовки // app.use(limiter);
message: "Слишком много запросов с этого IP адреса, пожалуйста попробуйте позже.",
skip: (req) => {
// Пропускаем health check запросы
return req.path === "/health" || req.path === "/manifest.json";
},
});
app.use(limiter);
// Специальный rate limiter для защиты от брутфорса (для логина и регистрации)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // максимум 5 попыток с одного IP
standardHeaders: true,
legacyHeaders: false,
message: "Слишком много попыток входа. Попробуйте позже.",
skipSuccessfulRequests: true, // не считаем успешные попытки
});
// Rate limiter для API запросов (более мягкий для обычных операций)
const apiLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 минут
max: 2000, // максимум 2000 запросов за 5 минут (400 в минуту)
standardHeaders: true,
legacyHeaders: false,
message: "Слишком много запросов к API. Пожалуйста, попробуйте позже.",
skip: (req) => {
// Пропускаем GET запросы (безопасные, не требуют защиты)
return req.method === "GET";
},
});
// Статические файлы // Статические файлы
app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(path.join(__dirname, "public")));
@ -281,9 +218,6 @@ app.get("/browserconfig.xml", (req, res) => {
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json()); app.use(bodyParser.json());
// Rate limiter для API (применяется ко всем API запросам)
app.use("/api/", apiLimiter);
// Настройка сессий с хранением в SQLite // Настройка сессий с хранением в SQLite
app.use( app.use(
session({ session({
@ -296,9 +230,7 @@ app.use(
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === "production", // куки только через HTTPS в продакшене secure: false, // в продакшене установить true с HTTPS
httpOnly: true, // куки недоступны для JavaScript (защита от XSS)
sameSite: "strict", // защита от CSRF атак
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
}, },
}) })
@ -853,7 +785,7 @@ app.get("/register", (req, res) => {
}); });
// API регистрации // API регистрации
app.post("/api/register", authLimiter, async (req, res) => { app.post("/api/register", async (req, res) => {
const { username, password, confirmPassword } = req.body; const { username, password, confirmPassword } = req.body;
// Валидация // Валидация
@ -909,7 +841,7 @@ app.post("/api/register", authLimiter, async (req, res) => {
}); });
// API входа // API входа
app.post("/api/login", authLimiter, async (req, res) => { app.post("/api/login", async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
@ -967,7 +899,7 @@ app.post("/api/login", authLimiter, async (req, res) => {
}); });
// Обработка входа (старый маршрут для совместимости) // Обработка входа (старый маршрут для совместимости)
app.post("/login", authLimiter, async (req, res) => { app.post("/login", async (req, res) => {
const { password } = req.body; const { password } = req.body;
const correctPassword = process.env.APP_PASSWORD; const correctPassword = process.env.APP_PASSWORD;
@ -3029,7 +2961,7 @@ app.post("/api/user/2fa/disable", requireApiAuth, async (req, res) => {
}); });
// API для проверки 2FA кода при входе // API для проверки 2FA кода при входе
app.post("/api/user/2fa/verify", authLimiter, async (req, res) => { app.post("/api/user/2fa/verify", async (req, res) => {
const { username, token } = req.body; const { username, token } = req.body;
if (!username || !token) { if (!username || !token) {

View File

@ -122,7 +122,7 @@ export const InstallPrompt: React.FC = () => {
Установите приложение для быстрого доступа Установите приложение для быстрого доступа
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "8px" }}>
<button <button
onClick={handleInstallClick} onClick={handleInstallClick}
style={{ style={{
@ -137,8 +137,6 @@ export const InstallPrompt: React.FC = () => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "6px", gap: "6px",
flex: "1 1 auto",
minWidth: "100px",
}} }}
> >
<Icon icon="mdi:download" width="18" height="18" /> <Icon icon="mdi:download" width="18" height="18" />
@ -156,7 +154,6 @@ export const InstallPrompt: React.FC = () => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
opacity: 0.6, opacity: 0.6,
flex: "0 0 auto",
}} }}
aria-label="Закрыть" aria-label="Закрыть"
> >

View File

@ -139,18 +139,18 @@ export const GenerateTagsModal: React.FC<GenerateTagsModalProps> = ({
}} }}
> >
<h4 style={{ margin: 0, fontSize: "16px" }}>Предлагаемые теги:</h4> <h4 style={{ margin: 0, fontSize: "16px" }}>Предлагаемые теги:</h4>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "10px" }}>
<button <button
className="btn-secondary" className="btn-secondary"
onClick={handleSelectAll} onClick={handleSelectAll}
style={{ fontSize: "12px", padding: "5px 10px", flex: "1 1 auto", minWidth: "100px" }} style={{ fontSize: "12px", padding: "5px 10px" }}
> >
Выбрать все Выбрать все
</button> </button>
<button <button
className="btn-secondary" className="btn-secondary"
onClick={handleDeselectAll} onClick={handleDeselectAll}
style={{ fontSize: "12px", padding: "5px 10px", flex: "1 1 auto", minWidth: "100px" }} style={{ fontSize: "12px", padding: "5px 10px" }}
> >
Снять все Снять все
</button> </button>

View File

@ -1081,8 +1081,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
<FileUpload files={files} onChange={setFiles} /> <FileUpload files={files} onChange={setFiles} />
{user?.is_public_profile === 1 && ( {user?.is_public_profile === 1 && (
<div className="privacy-toggle-container" style={{ marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}> <div className="privacy-toggle-container" style={{ marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", flex: "1 1 auto" }}> <label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
<input <input
type="checkbox" type="checkbox"
checked={isPrivate} checked={isPrivate}

View File

@ -255,12 +255,11 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
}} }}
/> />
</div> </div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "10px" }}>
<button <button
className="btnSave" className="btnSave"
onClick={handleEnable} onClick={handleEnable}
disabled={isLoading || verificationCode.length !== 6} disabled={isLoading || verificationCode.length !== 6}
style={{ flex: "1 1 auto", minWidth: "120px" }}
> >
{isLoading ? "Включение..." : "Включить 2FA"} {isLoading ? "Включение..." : "Включить 2FA"}
</button> </button>
@ -271,7 +270,6 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
setVerificationCode(""); setVerificationCode("");
}} }}
disabled={isLoading} disabled={isLoading}
style={{ flex: "1 1 auto", minWidth: "100px" }}
> >
Отмена Отмена
</button> </button>
@ -307,12 +305,11 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}> <p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
Двухфакторная аутентификация активна для вашего аккаунта. Двухфакторная аутентификация активна для вашего аккаунта.
</p> </p>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "10px" }}>
<button <button
className="btnSave" className="btnSave"
onClick={handleGenerateBackupCodes} onClick={handleGenerateBackupCodes}
disabled={isGeneratingBackupCodes} disabled={isGeneratingBackupCodes}
style={{ flex: "1 1 auto", minWidth: "200px" }}
> >
{isGeneratingBackupCodes ? ( {isGeneratingBackupCodes ? (
<> <>
@ -327,7 +324,6 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
<button <button
className="btn-danger" className="btn-danger"
onClick={() => setShowDisableModal(true)} onClick={() => setShowDisableModal(true)}
style={{ flex: "1 1 auto", minWidth: "140px" }}
> >
<Icon icon="mdi:shield-off" /> Отключить 2FA <Icon icon="mdi:shield-off" /> Отключить 2FA
</button> </button>

View File

@ -272,12 +272,11 @@ const LoginPage: React.FC = () => {
}} }}
/> />
</div> </div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "10px" }}>
<button <button
type="submit" type="submit"
className="btnSave" className="btnSave"
disabled={isLoading || !twoFactorCode.trim()} disabled={isLoading || !twoFactorCode.trim()}
style={{ flex: "1 1 auto", minWidth: "120px" }}
> >
{isLoading ? "Проверка..." : "Продолжить"} {isLoading ? "Проверка..." : "Продолжить"}
</button> </button>
@ -286,7 +285,6 @@ const LoginPage: React.FC = () => {
className="btn-danger" className="btn-danger"
onClick={handleBack} onClick={handleBack}
disabled={isLoading} disabled={isLoading}
style={{ flex: "1 1 auto", minWidth: "100px" }}
> >
Назад Назад
</button> </button>

View File

@ -127,7 +127,7 @@ const PublicProfilePage: React.FC = () => {
<div className="container"> <div className="container">
<header className="notes-header"> <header className="notes-header">
<div className="notes-header-left"> <div className="notes-header-left">
<div style={{ display: "flex", alignItems: "center", gap: "15px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
{profile.avatar ? ( {profile.avatar ? (
<img <img
src={profile.avatar} src={profile.avatar}