Compare commits
2 Commits
12c0870c8f
...
7d115e1845
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d115e1845 | ||
|
|
daa1c14d11 |
@ -8,6 +8,7 @@ const session = require("express-session");
|
||||
const SQLiteStore = require("connect-sqlite3")(session);
|
||||
const path = require("path");
|
||||
const helmet = require("helmet");
|
||||
const cors = require("cors");
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const bodyParser = require("body-parser");
|
||||
const multer = require("multer");
|
||||
@ -25,6 +26,38 @@ const PORT = process.env.PORT || 3001;
|
||||
// Доверяем всем прокси (nginx proxy manager должен передавать X-Forwarded-For)
|
||||
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");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
@ -185,12 +218,42 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Ограничение запросов (отключено для разработки)
|
||||
// const limiter = rateLimit({
|
||||
// windowMs: 15 * 60 * 1000, // 15 минут
|
||||
// max: 100, // максимум 100 запросов с одного IP
|
||||
// });
|
||||
// app.use(limiter);
|
||||
// Ограничение запросов (включено для защиты от DDoS и брутфорса)
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 минут
|
||||
max: 1000, // максимум 1000 запросов с одного IP
|
||||
standardHeaders: true, // вернёт информацию о rate limit в `RateLimit-*` заголовки
|
||||
legacyHeaders: false, // отключить `X-RateLimit-*` заголовки
|
||||
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")));
|
||||
@ -218,6 +281,9 @@ app.get("/browserconfig.xml", (req, res) => {
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// Rate limiter для API (применяется ко всем API запросам)
|
||||
app.use("/api/", apiLimiter);
|
||||
|
||||
// Настройка сессий с хранением в SQLite
|
||||
app.use(
|
||||
session({
|
||||
@ -230,7 +296,9 @@ app.use(
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // в продакшене установить true с HTTPS
|
||||
secure: process.env.NODE_ENV === "production", // куки только через HTTPS в продакшене
|
||||
httpOnly: true, // куки недоступны для JavaScript (защита от XSS)
|
||||
sameSite: "strict", // защита от CSRF атак
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
|
||||
},
|
||||
})
|
||||
@ -785,7 +853,7 @@ app.get("/register", (req, res) => {
|
||||
});
|
||||
|
||||
// API регистрации
|
||||
app.post("/api/register", async (req, res) => {
|
||||
app.post("/api/register", authLimiter, async (req, res) => {
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Валидация
|
||||
@ -841,7 +909,7 @@ app.post("/api/register", async (req, res) => {
|
||||
});
|
||||
|
||||
// API входа
|
||||
app.post("/api/login", async (req, res) => {
|
||||
app.post("/api/login", authLimiter, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
@ -899,7 +967,7 @@ app.post("/api/login", async (req, res) => {
|
||||
});
|
||||
|
||||
// Обработка входа (старый маршрут для совместимости)
|
||||
app.post("/login", async (req, res) => {
|
||||
app.post("/login", authLimiter, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
const correctPassword = process.env.APP_PASSWORD;
|
||||
|
||||
@ -2961,7 +3029,7 @@ app.post("/api/user/2fa/disable", requireApiAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// API для проверки 2FA кода при входе
|
||||
app.post("/api/user/2fa/verify", async (req, res) => {
|
||||
app.post("/api/user/2fa/verify", authLimiter, async (req, res) => {
|
||||
const { username, token } = req.body;
|
||||
|
||||
if (!username || !token) {
|
||||
|
||||
@ -122,7 +122,7 @@ export const InstallPrompt: React.FC = () => {
|
||||
Установите приложение для быстрого доступа
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
style={{
|
||||
@ -137,6 +137,8 @@ export const InstallPrompt: React.FC = () => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
flex: "1 1 auto",
|
||||
minWidth: "100px",
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:download" width="18" height="18" />
|
||||
@ -154,6 +156,7 @@ export const InstallPrompt: React.FC = () => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
opacity: 0.6,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
|
||||
@ -139,18 +139,18 @@ export const GenerateTagsModal: React.FC<GenerateTagsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0, fontSize: "16px" }}>Предлагаемые теги:</h4>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleSelectAll}
|
||||
style={{ fontSize: "12px", padding: "5px 10px" }}
|
||||
style={{ fontSize: "12px", padding: "5px 10px", flex: "1 1 auto", minWidth: "100px" }}
|
||||
>
|
||||
Выбрать все
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleDeselectAll}
|
||||
style={{ fontSize: "12px", padding: "5px 10px" }}
|
||||
style={{ fontSize: "12px", padding: "5px 10px", flex: "1 1 auto", minWidth: "100px" }}
|
||||
>
|
||||
Снять все
|
||||
</button>
|
||||
|
||||
@ -1081,8 +1081,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
<FileUpload files={files} onChange={setFiles} />
|
||||
|
||||
{user?.is_public_profile === 1 && (
|
||||
<div className="privacy-toggle-container" style={{ marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
|
||||
<div className="privacy-toggle-container" style={{ marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", flex: "1 1 auto" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
|
||||
@ -255,11 +255,12 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
className="btnSave"
|
||||
onClick={handleEnable}
|
||||
disabled={isLoading || verificationCode.length !== 6}
|
||||
style={{ flex: "1 1 auto", minWidth: "120px" }}
|
||||
>
|
||||
{isLoading ? "Включение..." : "Включить 2FA"}
|
||||
</button>
|
||||
@ -270,6 +271,7 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
setVerificationCode("");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
style={{ flex: "1 1 auto", minWidth: "100px" }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@ -305,11 +307,12 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||
Двухфакторная аутентификация активна для вашего аккаунта.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
className="btnSave"
|
||||
onClick={handleGenerateBackupCodes}
|
||||
disabled={isGeneratingBackupCodes}
|
||||
style={{ flex: "1 1 auto", minWidth: "200px" }}
|
||||
>
|
||||
{isGeneratingBackupCodes ? (
|
||||
<>
|
||||
@ -324,6 +327,7 @@ export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => setShowDisableModal(true)}
|
||||
style={{ flex: "1 1 auto", minWidth: "140px" }}
|
||||
>
|
||||
<Icon icon="mdi:shield-off" /> Отключить 2FA
|
||||
</button>
|
||||
|
||||
@ -272,11 +272,12 @@ const LoginPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btnSave"
|
||||
disabled={isLoading || !twoFactorCode.trim()}
|
||||
style={{ flex: "1 1 auto", minWidth: "120px" }}
|
||||
>
|
||||
{isLoading ? "Проверка..." : "Продолжить"}
|
||||
</button>
|
||||
@ -285,6 +286,7 @@ const LoginPage: React.FC = () => {
|
||||
className="btn-danger"
|
||||
onClick={handleBack}
|
||||
disabled={isLoading}
|
||||
style={{ flex: "1 1 auto", minWidth: "100px" }}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
@ -127,7 +127,7 @@ const PublicProfilePage: React.FC = () => {
|
||||
<div className="container">
|
||||
<header className="notes-header">
|
||||
<div className="notes-header-left">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px", flexWrap: "wrap" }}>
|
||||
{profile.avatar ? (
|
||||
<img
|
||||
src={profile.avatar}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user