photo / server /server.js
Starchik's picture
Upload 2261 files
270d713 verified
Raw History Blame Contribute Delete
49.9 kB
/**
* Vaultly Server
* -----------------
* Приватный файлообменник с шифрованием нулевого разглашения (zero-knowledge).
*
* Ключевая особенность: сервер НИКОГДА не видит ключи шифрования файлов.
* Файлы шифруются в браузере (AES-256-GCM, Web Crypto API) ДО отправки
* на сервер. Сервер хранит только зашифрованные байты + зашифрованные
* метаданные (имя, размер).
*
* Ключ файла хранится в "обёрнутом" виде (зашифрован мастер-ключом
* пользователя) — так пользователь видит список своих файлов после логина.
* Для публичных ссылок сырой ключ файла кладётся во фрагмент URL (после #),
* который браузер никогда не отправляет на сервер — сервер физически не
* может его узнать.
*
* Хранилище метаданных: простой JSON-файл (db.json). Для хобби-проекта
* этого достаточно; для продакшена замените на настоящую БД.
*
* Биометрия (WebAuthn + расширение PRF): биометрический вход не просто
* "подтверждает личность" — он ещё и восстанавливает мастер-ключ
* шифрования без ввода пароля. Это возможно благодаря расширению PRF:
* аутентификатор (Touch ID/Face ID/Windows Hello/отпечаток на Android)
* детерминированно выводит секрет, которым на клиенте обёрнут мастер-ключ.
* Сервер этот секрет никогда не видит — только обёрнутый (зашифрованный)
* мастер-ключ и публичный ключ WebAuthn-credential для проверки подписи.
* Поддержка PRF зависит от браузера/устройства — если её нет, кнопка
* биометрического входа для нового устройства скрыта.
*
* Ключи доступа регистрируются как discoverable (resident key): аутентификатор
* хранит их вместе с userHandle, поэтому при входе не нужно вводить ни пароль,
* ни логин — сервер узнаёт аккаунт из подписанного userHandle. Логин при входе
* принимается лишь как fallback для ключей, созданных до этого изменения.
*/
const express = require('express');
const multer = require('multer');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cors = require('cors');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const PORT = process.env.PORT || 7860;
// DATA_DIR — куда пишутся данные (БД, JWT-секрет, загруженные файлы).
// По умолчанию — рядом с сервером, но на платформах с эфемерной ФС
// (Render, Hugging Face Spaces и т.п.) нужно смонтировать постоянный том
// и указать его путь через переменную окружения DATA_DIR — тогда данные
// переживут перезапуск/передеплой контейнера.
const DATA_DIR = process.env.DATA_DIR || __dirname;
const DB_PATH = path.join(DATA_DIR, 'db.json');
const STORAGE_DIR = path.join(DATA_DIR, 'storage');
const SECRET_PATH = path.join(DATA_DIR, '.jwt-secret');
const DEFAULT_QUOTA_BYTES = 100 * 1024 * 1024 * 1024; // 100 GB на пользователя по умолчанию
// Через сколько дней содержимое корзины удаляется безвозвратно (0 — никогда).
// Без этого удалённые файлы лежали в storage/ вечно: из интерфейса они уходят,
// а место на диске занимают.
const TRASH_TTL_DAYS = Number.isFinite(Number(process.env.TRASH_TTL_DAYS))
? Number(process.env.TRASH_TTL_DAYS)
: 30;
const TRASH_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
// ---------- JWT-секрет: персистентный, переживает перезапуски сервера ----------
// Раньше секрет генерировался заново при каждом старте процесса — из-за этого
// любой перезапуск сервера (падение, авто-рестарт при разработке и т.п.)
// мгновенно делал недействительными ВСЕ выданные токены, и все запросы
// начинали падать с "Требуется авторизация"/"Недействительный токен",
// хотя внешне выглядело как случайный баг то тут, то там.
function loadOrCreateJwtSecret() {
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
if (fs.existsSync(SECRET_PATH)) return fs.readFileSync(SECRET_PATH, 'utf-8').trim();
const secret = crypto.randomBytes(48).toString('hex');
fs.writeFileSync(SECRET_PATH, secret, { mode: 0o600 });
return secret;
}
const JWT_SECRET = loadOrCreateJwtSecret();
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(DB_PATH)) {
fs.writeFileSync(DB_PATH, JSON.stringify({ users: [], folders: [], files: [], shares: [], webauthnCredentials: [] }, null, 2));
}
// ---------- простая JSON "БД" с транзакциями ----------
// Раньше сериализовалась только ЗАПИСЬ, а не весь цикл: два параллельных
// запроса читали одну и ту же версию db.json, мутировали каждый свою копию,
// и вторая запись затирала первую (создали две папки — осталась одна).
// Теперь под замком идёт весь цикл чтение→мутация→запись, а файл подменяется
// атомарным rename: падение процесса посреди записи больше не может оставить
// обрезанный db.json (то есть потерю всего аккаунта).
function readDb() {
const db = JSON.parse(fs.readFileSync(DB_PATH, 'utf-8'));
if (!db.webauthnCredentials) db.webauthnCredentials = [];
return db;
}
let dbLock = Promise.resolve();
function withDb(fn) {
const run = dbLock.then(async () => {
const db = readDb();
const result = await fn(db);
const tmp = DB_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(db, null, 2));
fs.renameSync(tmp, DB_PATH);
return result;
});
// ошибка (в т.ч. брошенный HttpError) не должна вставать поперёк очереди:
// изменения этой транзакции просто не записываются
dbLock = run.then(() => {}, () => {});
return run;
}
// Ошибка с HTTP-статусом — чтобы проверки внутри транзакции могли просто
// бросить исключение вместо отправки ответа из середины withDb.
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const httpError = (status, message) => new HttpError(status, message);
// ---------- хранилище блобов и корзина ----------
function removeBlob(storedName) {
try {
const blobPath = path.join(STORAGE_DIR, storedName);
if (fs.existsSync(blobPath)) fs.unlinkSync(blobPath);
} catch (e) {
// потеря блоба не должна валить транзакцию — запись в БД всё равно уходит
console.error('Не удалось удалить блоб', storedName, e);
}
}
// Занятое место. Файлы в корзине ТОЖЕ считаются: они физически лежат на диске,
// и делать вид, что корзина бесплатна, было бы нечестно.
function usedBytes(db, userId) {
return db.files.filter(f => f.userId === userId).reduce((sum, f) => sum + f.size, 0);
}
function trashedBytes(db, userId) {
return db.files.filter(f => f.userId === userId && f.deleted).reduce((sum, f) => sum + f.size, 0);
}
// Все папки поддерева, включая корневую (обход вниз по parentId).
function collectFolderIds(db, userId, rootId) {
const ids = [rootId];
for (let i = 0; i < ids.length; i++) {
for (const folder of db.folders) {
if (folder.userId === userId && folder.parentId === ids[i]) ids.push(folder.id);
}
}
return ids;
}
// Жёсткое удаление файлов: записи, блобы на диске и публичные ссылки на них.
function hardDeleteFiles(db, files) {
if (!files.length) return 0;
const ids = new Set(files.map(f => f.id));
for (const file of files) removeBlob(file.storedName);
db.files = db.files.filter(f => !ids.has(f.id));
db.shares = db.shares.filter(s => !ids.has(s.fileId));
return files.length;
}
// Жёсткое удаление папки со всем содержимым. Раньше удалялась только запись
// самой папки — вложенные папки и файлы оставались сиротами: в интерфейс уже
// не попадали, а место на диске занимали навсегда.
function hardDeleteFolderTree(db, userId, rootId) {
const folderIds = new Set(collectFolderIds(db, userId, rootId));
hardDeleteFiles(db, db.files.filter(f => f.userId === userId && folderIds.has(f.folderId)));
db.folders = db.folders.filter(f => !(f.userId === userId && folderIds.has(f.id)));
}
// Автоочистка корзины по сроку. Экспортируется, чтобы её можно было вызвать в тестах.
function purgeExpiredTrash() {
if (!(TRASH_TTL_DAYS > 0)) return Promise.resolve({ files: 0, folders: 0 });
const ttlMs = TRASH_TTL_DAYS * 24 * 60 * 60 * 1000;
return withDb((db) => {
const now = Date.now();
// у записей из старых версий deletedAt нет — начинаем отсчёт с этого прохода
for (const rec of db.files.concat(db.folders)) {
if (rec.deleted && !rec.deletedAt) rec.deletedAt = now;
}
const expired = (rec) => rec.deleted && rec.deletedAt && now - rec.deletedAt > ttlMs;
const files = db.files.filter(expired);
hardDeleteFiles(db, files);
const folders = db.folders.filter(expired);
for (const folder of folders) hardDeleteFolderTree(db, folder.userId, folder.id);
if (files.length || folders.length) {
console.log(`Корзина: безвозвратно удалено файлов ${files.length}, папок ${folders.length}`);
}
return { files: files.length, folders: folders.length };
});
}
// Не даём процессу падать из-за необработанных ошибок в асинхронных хендлерах
// (раньше это могло уронить сервер и — вместе со случайным JWT-секретом —
// разлогинить всех пользователей без видимой причины).
process.on('unhandledRejection', (err) => {
console.error('Необработанная ошибка (сервер продолжает работу):', err);
});
// оборачиваем async-хендлеры, чтобы отклонённый промис превращался в
// аккуратный ответ, а не в необработанное исключение
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
if (err instanceof HttpError) {
if (!res.headersSent) res.status(err.status).json({ error: err.message });
return;
}
console.error(err);
if (!res.headersSent) res.status(500).json({ error: 'Внутренняя ошибка сервера' });
});
}
// ---------- приложение ----------
const app = express();
app.use(cors());
app.use(express.json({ limit: '5mb' }));
app.use(express.static(path.join(__dirname, '..', 'public')));
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 * 1024 } });
function authMiddleware(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Требуется авторизация' });
try {
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.uid;
next();
} catch (e) {
return res.status(401).json({ error: 'Сессия истекла, войдите заново' });
}
}
function getRpID(req) {
// домен без порта и протокола, как того требует WebAuthn
return (req.hostname || 'localhost');
}
function getOrigin(req) {
return req.headers.origin || `${req.protocol}://${req.get('host')}`;
}
// ===================== AUTH =====================
app.post('/api/auth/register', asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password || password.length < 6) {
return res.status(400).json({ error: 'Логин обязателен, пароль минимум 6 символов' });
}
const passwordHash = await bcrypt.hash(password, 10);
// соль для клиентского PBKDF2 (вывод мастер-ключа шифрования) — отдельная от bcrypt
const kdfSalt = crypto.randomBytes(16).toString('hex');
// проверка занятости логина и вставка — в одной транзакции, иначе два
// одновременных запроса могли создать двух пользователей с одним именем
const user = await withDb((db) => {
if (db.users.find(u => u.username.toLowerCase() === username.toLowerCase())) {
throw httpError(409, 'Пользователь с таким именем уже существует');
}
const rec = {
id: uuidv4(),
username,
passwordHash,
kdfSalt,
quota: DEFAULT_QUOTA_BYTES,
createdAt: Date.now(),
};
db.users.push(rec);
return rec;
});
const token = jwt.sign({ uid: user.id }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, username: user.username, kdfSalt: user.kdfSalt });
}));
app.post('/api/auth/login', asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
const db = readDb();
const user = db.users.find(u => u.username.toLowerCase() === (username || '').toLowerCase());
if (!user) return res.status(401).json({ error: 'Неверный логин или пароль' });
const ok = await bcrypt.compare(password || '', user.passwordHash);
if (!ok) return res.status(401).json({ error: 'Неверный логин или пароль' });
const token = jwt.sign({ uid: user.id }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, username: user.username, kdfSalt: user.kdfSalt });
}));
app.get('/api/auth/me', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const user = db.users.find(u => u.id === req.userId);
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
// used включает корзину (файлы никуда с диска не делись), trashed показываем
// отдельной строкой, чтобы было видно, сколько освободит очистка
res.json({
username: user.username,
kdfSalt: user.kdfSalt,
quota: user.quota,
used: usedBytes(db, user.id),
trashed: trashedBytes(db, user.id),
trashTtlDays: TRASH_TTL_DAYS,
});
}));
// ===================== FOLDERS =====================
// Имена папок хранятся в открытом виде (сознательное упрощение — полное
// шифрование дерева папок сильно усложнило бы навигацию). Содержимое и
// имена ФАЙЛОВ — шифруются полностью.
app.get('/api/folders', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const parentId = req.query.parentId || null;
const folders = db.folders.filter(f => f.userId === req.userId && !f.deleted && f.parentId === parentId);
const files = db.files.filter(f => f.userId === req.userId && !f.deleted && f.folderId === parentId);
// хлебные крошки
const breadcrumb = [];
let cur = parentId;
while (cur) {
const fld = db.folders.find(f => f.id === cur);
if (!fld) break;
breadcrumb.unshift({ id: fld.id, name: fld.name });
cur = fld.parentId;
}
res.json({ folders, files, breadcrumb });
}));
app.post('/api/folders', authMiddleware, asyncHandler(async (req, res) => {
const { name, parentId } = req.body || {};
if (!name || !name.trim()) return res.status(400).json({ error: 'Укажите имя папки' });
const folder = await withDb((db) => {
const rec = {
id: uuidv4(),
userId: req.userId,
parentId: parentId || null,
name: name.trim(),
createdAt: Date.now(),
deleted: false,
deletedAt: null,
};
db.folders.push(rec);
return rec;
});
res.json(folder);
}));
app.patch('/api/folders/:id', authMiddleware, asyncHandler(async (req, res) => {
const folder = await withDb((db) => {
const rec = db.folders.find(f => f.id === req.params.id && f.userId === req.userId);
if (!rec) throw httpError(404, 'Папка не найдена');
if (req.body.name) rec.name = req.body.name.trim();
if ('parentId' in req.body) {
const target = req.body.parentId || null;
if (target) {
const dest = db.folders.find(f => f.id === target && f.userId === req.userId && !f.deleted);
if (!dest) throw httpError(404, 'Папка назначения не найдена');
// Защита от цикла: поднимаемся от новой папки-родителя к корню и следим,
// не встретится ли перемещаемая папка. Клиент этого проверить не может —
// у него на руках только текущий уровень дерева.
let cursor = dest;
while (cursor) {
if (cursor.id === rec.id) throw httpError(400, 'Нельзя переместить папку внутрь себя');
cursor = cursor.parentId ? db.folders.find(f => f.id === cursor.parentId) : null;
}
}
rec.parentId = target;
}
if ('deleted' in req.body) {
rec.deleted = !!req.body.deleted;
rec.deletedAt = rec.deleted ? Date.now() : null;
}
return rec;
});
res.json(folder);
}));
// Жёсткое удаление — вместе со всем содержимым (вложенные папки, файлы, блобы,
// публичные ссылки). Мягкое удаление в корзину делается через PATCH.
app.delete('/api/folders/:id', authMiddleware, asyncHandler(async (req, res) => {
await withDb((db) => {
const folder = db.folders.find(f => f.id === req.params.id && f.userId === req.userId);
if (!folder) throw httpError(404, 'Папка не найдена');
hardDeleteFolderTree(db, req.userId, folder.id);
});
res.json({ ok: true });
}));
// ===================== FILES =====================
app.post('/api/files/upload', authMiddleware, upload.single('blob'), asyncHandler(async (req, res) => {
const { folderId, size, contentIv, attrsEncrypted, attrsIv, keyWrapped, keyWrapIv } = req.body;
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
const incomingSize = Number(size) || req.file.size;
const storedName = uuidv4();
fs.writeFileSync(path.join(STORAGE_DIR, storedName), req.file.buffer);
try {
const fileRec = await withDb((db) => {
const user = db.users.find(u => u.id === req.userId);
if (!user) throw httpError(401, 'Сессия истекла, войдите заново');
// проверка квоты и вставка — в одной транзакции: разнесённые, они
// позволяли параллельным загрузкам вместе перескочить лимит
if (usedBytes(db, req.userId) + incomingSize > user.quota) {
throw httpError(413, 'Превышена квота хранилища');
}
const rec = {
id: uuidv4(),
userId: req.userId,
folderId: folderId || null,
storedName,
size: incomingSize,
contentIv,
attrsEncrypted,
attrsIv,
keyWrapped,
keyWrapIv,
createdAt: Date.now(),
deleted: false,
deletedAt: null,
};
db.files.push(rec);
return rec;
});
res.json(fileRec);
} catch (e) {
// блоб уже на диске, а записи о нём не будет — убираем, чтобы не копился мусор
removeBlob(storedName);
throw e;
}
}));
app.get('/api/files/:id/meta', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) return res.status(404).json({ error: 'Файл не найден' });
res.json(file);
}));
app.get('/api/files/:id/download', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) return res.status(404).json({ error: 'Файл не найден' });
const filePath = path.join(STORAGE_DIR, file.storedName);
if (!fs.existsSync(filePath)) return res.status(410).json({ error: 'Содержимое файла отсутствует на диске' });
res.sendFile(filePath);
}));
app.patch('/api/files/:id', authMiddleware, asyncHandler(async (req, res) => {
const file = await withDb((db) => {
const rec = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!rec) throw httpError(404, 'Файл не найден');
// переименование зашифровано на клиенте — сюда прилетают новые attrsEncrypted/attrsIv
if (req.body.attrsEncrypted) rec.attrsEncrypted = req.body.attrsEncrypted;
if (req.body.attrsIv) rec.attrsIv = req.body.attrsIv;
if ('folderId' in req.body) {
const target = req.body.folderId || null;
if (target && !db.folders.find(f => f.id === target && f.userId === req.userId && !f.deleted)) {
throw httpError(404, 'Папка назначения не найдена');
}
rec.folderId = target;
}
if ('deleted' in req.body) {
rec.deleted = !!req.body.deleted;
// deletedAt — точка отсчёта для автоочистки корзины
rec.deletedAt = rec.deleted ? Date.now() : null;
}
return rec;
});
res.json(file);
}));
app.delete('/api/files/:id', authMiddleware, asyncHandler(async (req, res) => {
await withDb((db) => {
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) throw httpError(404, 'Файл не найден');
hardDeleteFiles(db, [file]);
});
res.json({ ok: true });
}));
// ===================== RUBBISH BIN =====================
app.get('/api/rubbish', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const folders = db.folders.filter(f => f.userId === req.userId && f.deleted);
const files = db.files.filter(f => f.userId === req.userId && f.deleted);
res.json({ folders, files, size: trashedBytes(db, req.userId), trashTtlDays: TRASH_TTL_DAYS });
}));
// Очистка корзины целиком: записи, блобы, публичные ссылки. Папки удаляются
// вместе с содержимым — внутри них могут лежать файлы, которые сами по себе
// как удалённые не помечались.
app.post('/api/rubbish/empty', authMiddleware, asyncHandler(async (req, res) => {
const removed = await withDb((db) => {
const files = db.files.filter(f => f.userId === req.userId && f.deleted);
const folders = db.folders.filter(f => f.userId === req.userId && f.deleted);
const freed = files.reduce((sum, f) => sum + f.size, 0);
hardDeleteFiles(db, files);
for (const folder of folders) hardDeleteFolderTree(db, req.userId, folder.id);
return { files: files.length, folders: folders.length, freed };
});
res.json({ ok: true, ...removed });
}));
// ===================== SHARES =====================
// Сырой ключ файла сюда НЕ передаётся и не хранится.
//
// Режим без пароля: ключ остаётся у клиента и попадает во фрагмент ссылки
// (#...), который браузер не отправляет на сервер.
// Режим с паролем: ключ файла оборачивается ключом, выведенным из пароля
// (PBKDF2 на клиенте), и на сервер приходит уже обёрнутым — расшифровать его
// без пароля нельзя, а ссылка при этом ключа не содержит вовсе. Дополнительно
// клиент присылает "верификатор" (вторая половина того же PBKDF2-вывода);
// сервер хранит только его SHA-256 и по нему пускает к обёртке и к байтам.
const SHARE_LIMITS = { salt: 64, keyWrapped: 128, keyWrapIv: 32, verifier: 128 };
const UNLOCK_MAX_ATTEMPTS = 10;
const UNLOCK_WINDOW_MS = 5 * 60 * 1000;
const unlockAttempts = new Map(); // publicId -> { count, resetAt }
const sha256Hex = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
// Ограничиваем онлайн-перебор пароля по ссылке (офлайн-перебор тем, у кого на
// руках db.json, этим не остановить — об этом честно сказано в README).
function checkUnlockRate(publicId) {
const now = Date.now();
if (unlockAttempts.size > 500) {
for (const [key, rec] of unlockAttempts) if (now > rec.resetAt) unlockAttempts.delete(key);
}
const rec = unlockAttempts.get(publicId);
if (!rec || now > rec.resetAt) {
unlockAttempts.set(publicId, { count: 1, resetAt: now + UNLOCK_WINDOW_MS });
return;
}
rec.count += 1;
if (rec.count > UNLOCK_MAX_ATTEMPTS) {
throw httpError(429, 'Слишком много попыток ввода пароля — подождите несколько минут');
}
}
function verifierMatches(share, verifier) {
if (!share.password) return true;
if (!verifier) return false;
const expected = Buffer.from(share.password.verifierHash, 'hex');
const actual = Buffer.from(sha256Hex(verifier), 'hex');
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
// Из присланного клиентом объекта в запись БД попадает всё, кроме верификатора:
// вместо него храним хэш, чтобы утечка db.json не давала доступ к обёртке.
function normalizeSharePassword(input) {
if (!input) return null;
const { salt, keyWrapped, keyWrapIv, verifier } = input;
if (!salt || !keyWrapped || !keyWrapIv || !verifier) {
throw httpError(400, 'Некорректные данные пароля для ссылки');
}
for (const [field, value] of Object.entries({ salt, keyWrapped, keyWrapIv, verifier })) {
if (typeof value !== 'string' || value.length > SHARE_LIMITS[field]) {
throw httpError(400, 'Некорректные данные пароля для ссылки');
}
}
return { salt, keyWrapped, keyWrapIv, verifierHash: sha256Hex(verifier) };
}
// Пропущенное поле означает «оставить как было»: модалка настроек не знает
// пароля уже защищённой ссылки и не смогла бы прислать его заново, а срок
// хранится абсолютным моментом и в «часах до истечения» не восстанавливается.
// Явный null (или пустая строка) — снять ограничение.
function parseShareSettings(body) {
const { ttlHours, maxDownloads, password } = body || {};
const settings = {};
if (ttlHours !== undefined) {
if (ttlHours === null || ttlHours === '') {
settings.expiresAt = null;
} else {
const hours = Number(ttlHours);
if (!Number.isFinite(hours) || hours <= 0) throw httpError(400, 'Некорректный срок действия ссылки');
settings.expiresAt = Date.now() + hours * 60 * 60 * 1000;
}
}
if (maxDownloads !== undefined) {
if (maxDownloads === null || maxDownloads === '') {
settings.maxDownloads = null;
} else {
const value = Number(maxDownloads);
if (!Number.isInteger(value) || value < 1) throw httpError(400, 'Лимит скачиваний должен быть целым числом от 1');
settings.maxDownloads = value;
}
}
if (password !== undefined) settings.password = normalizeSharePassword(password);
return settings;
}
const shareInfo = (share) => ({
publicId: share.publicId,
expiresAt: share.expiresAt || null,
maxDownloads: share.maxDownloads || null,
downloads: share.downloads || 0,
requiresPassword: !!share.password,
});
// Находит живую ссылку или бросает понятную ошибку (не найдена / файл удалён /
// срок истёк / лимит исчерпан).
function resolveShare(db, publicId) {
const share = db.shares.find(s => s.publicId === publicId);
if (!share) throw httpError(404, 'Ссылка не найдена');
const file = db.files.find(f => f.id === share.fileId && !f.deleted);
if (!file) throw httpError(404, 'Файл больше недоступен');
if (share.expiresAt && Date.now() > share.expiresAt) throw httpError(410, 'Срок действия ссылки истёк');
if (share.maxDownloads && (share.downloads || 0) >= share.maxDownloads) {
throw httpError(410, 'Лимит скачиваний исчерпан');
}
return { share, file };
}
app.post('/api/files/:id/share', authMiddleware, asyncHandler(async (req, res) => {
const settings = parseShareSettings(req.body);
const share = await withDb((db) => {
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) throw httpError(404, 'Файл не найден');
let rec = db.shares.find(s => s.fileId === file.id);
if (!rec) {
rec = {
publicId: uuidv4(),
fileId: file.id,
createdAt: Date.now(),
downloads: 0,
expiresAt: null,
maxDownloads: null,
password: null,
};
db.shares.push(rec);
}
if ('maxDownloads' in settings && (rec.maxDownloads || null) !== settings.maxDownloads) {
// лимит изменили — счётчик начинаем заново, иначе ссылка, уже упёршаяся
// в старый лимит, осталась бы мёртвой при попытке его поднять
rec.downloads = 0;
}
if ('expiresAt' in settings) rec.expiresAt = settings.expiresAt;
if ('maxDownloads' in settings) rec.maxDownloads = settings.maxDownloads;
if ('password' in settings) rec.password = settings.password;
return rec;
});
res.json(shareInfo(share));
}));
// Текущие настройки ссылки владельцу файла (без секретов).
app.get('/api/files/:id/share', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) return res.status(404).json({ error: 'Файл не найден' });
const share = db.shares.find(s => s.fileId === file.id);
if (!share) return res.status(404).json({ error: 'Ссылка не найдена' });
res.json(shareInfo(share));
}));
app.delete('/api/files/:id/share', authMiddleware, asyncHandler(async (req, res) => {
await withDb((db) => {
const file = db.files.find(f => f.id === req.params.id && f.userId === req.userId);
if (!file) throw httpError(404, 'Файл не найден');
db.shares = db.shares.filter(s => s.fileId !== file.id);
});
res.json({ ok: true });
}));
// Публичные эндпоинты — без авторизации, для страницы share.html
app.get('/api/share/:publicId/meta', asyncHandler(async (req, res) => {
const db = readDb();
const { share, file } = resolveShare(db, req.params.publicId);
res.json({
size: file.size,
contentIv: file.contentIv,
attrsEncrypted: file.attrsEncrypted,
attrsIv: file.attrsIv,
expiresAt: share.expiresAt || null,
maxDownloads: share.maxDownloads || null,
downloads: share.downloads || 0,
requiresPassword: !!share.password,
// соль публична по своей природе — она нужна, чтобы вывести ключ из пароля.
// Саму обёртку ключа здесь НЕ отдаём: только после проверки пароля в /unlock.
passwordSalt: share.password ? share.password.salt : null,
});
}));
// Обмен "верификатора" (доказательства знания пароля) на обёрнутый ключ файла.
app.post('/api/share/:publicId/unlock', asyncHandler(async (req, res) => {
const { verifier } = req.body || {};
const db = readDb();
const { share } = resolveShare(db, req.params.publicId);
if (!share.password) return res.status(400).json({ error: 'Эта ссылка не защищена паролем' });
checkUnlockRate(share.publicId);
if (!verifierMatches(share, verifier)) return res.status(401).json({ error: 'Неверный пароль' });
unlockAttempts.delete(share.publicId);
res.json({ keyWrapped: share.password.keyWrapped, keyWrapIv: share.password.keyWrapIv });
}));
app.get('/api/share/:publicId/download', asyncHandler(async (req, res) => {
// Счётчик увеличиваем до отправки байтов. Оборванная закачка тоже расходует
// лимит — иначе его можно было бы обойти, обрывая соединение.
const { file } = await withDb((db) => {
const resolved = resolveShare(db, req.params.publicId);
if (resolved.share.password && !verifierMatches(resolved.share, req.headers['x-share-verifier'])) {
throw httpError(401, 'Требуется пароль для скачивания');
}
resolved.share.downloads = (resolved.share.downloads || 0) + 1;
return resolved;
});
const filePath = path.join(STORAGE_DIR, file.storedName);
if (!fs.existsSync(filePath)) return res.status(410).json({ error: 'Содержимое файла отсутствует на диске' });
res.sendFile(filePath);
}));
// ===================== WEBAUTHN (биометрия) =====================
// Challenges — временные, живут только в памяти процесса (не переживают
// перезапуск сервера, что абсолютно нормально для сессии из нескольких секунд).
const regChallenges = new Map(); // userId -> challenge
const loginChallenges = new Map(); // challengeId -> { challenge, userId } (userId=null — вход без логина)
// Фиксированная "соль" для PRF-расширения WebAuthn. Не секрет — это просто
// доменный разделитель, чтобы PRF-вывод этого приложения не совпадал с
// PRF-выводом какого-то другого сайта на том же ключе устройства.
const PRF_SALT_B64URL = Buffer.from('vaultly-master-key-unlock-v1').toString('base64url');
app.get('/api/webauthn/prf-salt', (req, res) => res.json({ salt: PRF_SALT_B64URL }));
app.post('/api/webauthn/register-options', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const user = db.users.find(u => u.id === req.userId);
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
const existing = db.webauthnCredentials.filter(c => c.userId === user.id);
const options = await generateRegistrationOptions({
rpName: 'Vaultly',
rpID: getRpID(req),
userName: user.username,
userDisplayName: user.username, // подпись аккаунта в системном списке ключей доступа
userID: Buffer.from(user.id, 'utf8'),
attestationType: 'none',
excludeCredentials: existing.map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: {
// residentKey: 'required' — ключ хранится НА устройстве вместе с userHandle,
// поэтому при входе браузер сам подставляет аккаунт и логин вводить не нужно.
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
authenticatorAttachment: 'platform',
},
// credProps — чтобы узнать, действительно ли ключ получился discoverable
extensions: { prf: {}, credProps: true },
});
regChallenges.set(user.id, options.challenge);
res.json(options);
}));
app.post('/api/webauthn/register-verify', authMiddleware, asyncHandler(async (req, res) => {
const { attestationResponse, deviceLabel } = req.body || {};
const db = readDb();
const user = db.users.find(u => u.id === req.userId);
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
const expectedChallenge = regChallenges.get(user.id);
if (!expectedChallenge) {
return res.status(400).json({ error: 'Запрос на регистрацию устарел, попробуйте ещё раз' });
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: attestationResponse,
expectedChallenge,
expectedOrigin: getOrigin(req),
expectedRPID: getRpID(req),
});
} catch (e) {
regChallenges.delete(user.id);
return res.status(400).json({ error: 'Не удалось подтвердить биометрический ключ: ' + e.message });
}
regChallenges.delete(user.id);
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'Не удалось подтвердить биометрический ключ' });
}
const { credential } = verification.registrationInfo;
// rk === false означает, что аутентификатор сохранил ключ не на себе —
// тогда вход без логина через этот ключ невозможен (нужен allowCredentials).
const rk = attestationResponse?.clientExtensionResults?.credProps?.rk;
await withDb((tx) => {
tx.webauthnCredentials.push({
id: credential.id,
userId: user.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
counter: credential.counter,
transports: credential.transports || [],
deviceLabel: (deviceLabel || 'Это устройство').slice(0, 60),
discoverable: rk !== false,
wrappedMasterKey: null,
createdAt: Date.now(),
});
});
res.json({ ok: true, credentialId: credential.id });
}));
// Сохраняем обёрнутый (зашифрованным на клиенте PRF-секретом) мастер-ключ
// для конкретного credential — это то, что позволяет следующему входу
// пройти вообще без пароля.
app.post('/api/webauthn/wrap-key', authMiddleware, asyncHandler(async (req, res) => {
const { credentialId, wrappedMasterKey } = req.body || {};
if (!credentialId || !wrappedMasterKey?.data || !wrappedMasterKey?.iv) {
return res.status(400).json({ error: 'Некорректные данные' });
}
await withDb((db) => {
const cred = db.webauthnCredentials.find(c => c.id === credentialId && c.userId === req.userId);
if (!cred) throw httpError(404, 'Ключ доступа не найден');
cred.wrappedMasterKey = wrappedMasterKey;
});
res.json({ ok: true });
}));
app.get('/api/webauthn/credentials', authMiddleware, asyncHandler(async (req, res) => {
const db = readDb();
const list = db.webauthnCredentials
.filter(c => c.userId === req.userId)
.map(c => ({ id: c.id, deviceLabel: c.deviceLabel, createdAt: c.createdAt, passwordless: !!c.wrappedMasterKey, discoverable: c.discoverable !== false }));
res.json({ credentials: list });
}));
app.delete('/api/webauthn/credentials/:id', authMiddleware, asyncHandler(async (req, res) => {
await withDb((db) => {
db.webauthnCredentials = db.webauthnCredentials.filter(c => !(c.id === req.params.id && c.userId === req.userId));
});
res.json({ ok: true });
}));
// Вход по биометрии — без обычной авторизации (это ей на замену).
// Логин указывать НЕ нужно: ключ доступа discoverable, аутентификатор сам хранит
// userHandle и сообщает его при подписи. Логин принимается только как fallback
// для ключей, зарегистрированных до перехода на resident keys.
app.post('/api/webauthn/login-options', asyncHandler(async (req, res) => {
const { username } = req.body || {};
const db = readDb();
let userId = null;
let allowCredentials;
if (username) {
const user = db.users.find(u => u.username.toLowerCase() === username.toLowerCase());
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
const creds = db.webauthnCredentials.filter(c => c.userId === user.id && c.wrappedMasterKey);
if (!creds.length) {
return res.status(404).json({ error: 'Для этого аккаунта не настроен беспарольный вход на этом или другом устройстве' });
}
userId = user.id;
allowCredentials = creds.map(c => ({ id: c.id, transports: c.transports }));
} else {
// Беспарольный режим без логина: allowCredentials не передаём вовсе —
// браузер сам покажет ключи доступа, сохранённые на этом устройстве.
if (!db.webauthnCredentials.some(c => c.wrappedMasterKey)) {
return res.status(404).json({ error: 'На сервере нет ни одного ключа доступа для беспарольного входа — включите биометрию после входа паролем' });
}
}
const options = await generateAuthenticationOptions({
rpID: getRpID(req),
userVerification: 'required',
...(allowCredentials ? { allowCredentials } : {}),
extensions: { prf: { eval: { first: PRF_SALT_B64URL } } },
});
const challengeId = uuidv4();
loginChallenges.set(challengeId, { challenge: options.challenge, userId });
// unref: challenge живёт в памяти, и таймер его уборки не должен держать
// процесс живым (иначе тесты висели бы после последней проверки ещё 5 минут)
setTimeout(() => loginChallenges.delete(challengeId), 5 * 60 * 1000).unref();
res.json({ options, challengeId });
}));
app.post('/api/webauthn/login-verify', asyncHandler(async (req, res) => {
const { assertionResponse, challengeId } = req.body || {};
if (!assertionResponse?.id) return res.status(400).json({ error: 'Некорректные данные' });
const pending = loginChallenges.get(challengeId);
if (!pending) return res.status(400).json({ error: 'Запрос на вход устарел, попробуйте ещё раз' });
const db = readDb();
// Кто именно вошёл: при входе с логином — пользователь из challenge,
// при входе без логина — userHandle, который аутентификатор вернул вместе
// с подписью (мы кладём туда user.id при регистрации ключа).
let userId = pending.userId;
if (!userId) {
const handle = assertionResponse?.response?.userHandle;
if (!handle) {
loginChallenges.delete(challengeId);
return res.status(400).json({ error: 'Устройство не сообщило, какой это аккаунт — введите логин и попробуйте снова' });
}
userId = Buffer.from(handle, 'base64url').toString('utf8');
}
const cred = db.webauthnCredentials.find(c => c.id === assertionResponse.id && c.userId === userId);
if (!cred) return res.status(404).json({ error: 'Ключ доступа не найден' });
if (!cred.wrappedMasterKey) {
return res.status(400).json({ error: 'На этом ключе доступа не настроен беспарольный вход — войдите паролем' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge: pending.challenge,
expectedOrigin: getOrigin(req),
expectedRPID: getRpID(req),
credential: {
id: cred.id,
publicKey: Buffer.from(cred.publicKey, 'base64'),
counter: cred.counter,
transports: cred.transports,
},
});
} catch (e) {
loginChallenges.delete(challengeId);
return res.status(400).json({ error: 'Не удалось подтвердить вход: ' + e.message });
}
loginChallenges.delete(challengeId);
if (!verification.verified) return res.status(400).json({ error: 'Не удалось подтвердить вход' });
cred.counter = verification.authenticationInfo.newCounter;
await withDb((tx) => {
const stored = tx.webauthnCredentials.find(c => c.id === cred.id && c.userId === userId);
if (stored) stored.counter = verification.authenticationInfo.newCounter;
});
const user = db.users.find(u => u.id === userId);
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
const token = jwt.sign({ uid: user.id }, JWT_SECRET, { expiresIn: '30d' });
res.json({
token,
username: user.username,
kdfSalt: user.kdfSalt,
wrappedMasterKey: cred.wrappedMasterKey,
});
}));
// ---------- автоочистка корзины ----------
if (TRASH_TTL_DAYS > 0) {
purgeExpiredTrash().catch(e => console.error('Не удалось очистить корзину при старте:', e));
const sweep = setInterval(() => {
purgeExpiredTrash().catch(e => console.error('Не удалось очистить корзину:', e));
}, TRASH_SWEEP_INTERVAL_MS);
// таймер не должен держать процесс живым (иначе тесты не завершаются)
sweep.unref();
}
// Запускаем сервер только при прямом вызове (`node server.js`), чтобы тесты
// могли импортировать приложение и поднять его на случайном порту.
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Vaultly server запущен: http://localhost:${PORT}`);
});
}
module.exports = { app, purgeExpiredTrash };