const express = require('express'); const Busboy = require('busboy'); const { DatabaseSync } = require('node:sqlite'); const { randomBytes, randomInt, createHmac, timingSafeEqual } = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { pipeline } = require('node:stream/promises'); const ROOT = __dirname, DATA = path.join(ROOT, 'data'); const MAX = 4 * 1024 ** 3; fs.mkdirSync(DATA, { recursive: true }); const db = new DatabaseSync(path.join(DATA, 'files.sqlite')); db.exec('CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, name TEXT, mime TEXT, expires INTEGER)'); const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 'loopback'); app.use(express.json({ limit: '4kb' })); const password = () => fs.readFileSync(path.join(ROOT, '.passwd'), 'utf8').trim(); const digest = s => createHmac('sha256', password()).update(s).digest('hex'); function authenticated(req) { const token = (req.headers.cookie || '').split(';').map(s => s.trim()).find(s => s.startsWith('session='))?.slice(8); if (!token) return false; const [expires, signature] = token.split('.'); return Number(expires) > Date.now() && signature?.length === 64 && timingSafeEqual(Buffer.from(signature), Buffer.from(digest(expires))); } app.post('/api/login', (req, res) => { const given = createHmac('sha256', 'compare').update(String(req.body.password || '')).digest(); const expected = createHmac('sha256', 'compare').update(password()).digest(); if (!timingSafeEqual(given, expected)) return res.status(401).json({ error: 'Неверный пароль' }); const expires = String(Date.now() + 7 * 86400000); res.setHeader('Set-Cookie', `session=${expires}.${digest(expires)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800${req.secure ? '; Secure' : ''}`); res.json({ ok: true }); }); app.get('/api/session', (req, res) => { res.set('Cache-Control', 'no-store').json({ authenticated: authenticated(req) }); }); let diskSpace = null; async function refreshDiskSpace() { try { const stats = await fs.promises.statfs(DATA); diskSpace = { free: stats.bavail * stats.bsize, total: stats.blocks * stats.bsize }; } catch (error) { diskSpace = null; console.error('Cannot check storage space:', error.message); } } refreshDiskSpace(); setInterval(refreshDiskSpace, 60000).unref(); app.get('/api/storage', (req, res) => { res.set('Cache-Control', 'no-store'); if (!authenticated(req)) return res.sendStatus(401); if (!diskSpace) return res.status(503).json({ error: 'Не удалось проверить свободное место' }); res.json(diskSpace); }); app.post('/api/upload', async (req, res) => { if (!authenticated(req)) return res.status(401).json({ error: 'Введите пароль заново' }); if (req.headers['sec-fetch-site'] === 'cross-site') return res.sendStatus(403); const days = Number(req.query.days); if (!Number.isSafeInteger(days) || days < 1 || days > 3650) return res.status(400).json({ error: 'Укажите от 1 до 3650 дней' }); const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; let id; do { id = Array.from({ length: 5 }, () => alphabet[randomInt(36)]).join(''); } while (fs.existsSync(path.join(DATA, id)) || db.prepare('SELECT id FROM files WHERE id = ?').get(id)); const target = path.join(DATA, id); fs.writeFileSync(target, '', { flag: 'wx', mode: 0o600 }); let name, mime, task, invalid = false, count = 0; try { const parser = Busboy({ headers: req.headers, defParamCharset: 'utf8', limits: { fileSize: MAX, files: 1, fields: 0, parts: 2 } }); parser.on('file', (_, stream, info) => { count++; name = path.basename(info.filename.replace(/\\/g, '/')).replace(/[\x00-\x1f\x7f]/g, '') || 'file'; mime = info.mimeType; stream.on('limit', () => { invalid = true; }); task = pipeline(stream, fs.createWriteStream(target, { flags: 'w', mode: 0o600 })); task.catch(() => {}); }); parser.on('fieldsLimit', () => { invalid = true; }); parser.on('partsLimit', () => { invalid = true; }); parser.on('filesLimit', () => { invalid = true; }); await pipeline(req, parser); if (task) await task; if (invalid || count !== 1) throw new Error('Выберите один файл до 4 ГБ'); const expires = Date.now() + days * 86400000; db.prepare('INSERT INTO files VALUES (?, ?, ?, ?)').run(id, name, mime, expires); res.json({ path: `/${id}`, expires }); } catch (error) { if (task) await task.catch(() => {}); fs.rmSync(target, { force: true }); if (!res.headersSent && !res.destroyed) res.status(400).json({ error: invalid ? 'Максимальный размер 4 ГБ' : 'Не удалось загрузить файл' }); } }); app.get('/:id', (req, res, next) => { if (!/^[a-z0-9]{5}$/i.test(req.params.id)) return next(); const file = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id.toLowerCase()); if (!file || file.expires <= Date.now()) return res.status(404).send('Файл не найден или срок ссылки истёк'); const inline = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon']); res.set({ 'Content-Type': inline.has(file.mime) ? file.mime : 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store', 'Content-Disposition': `${inline.has(file.mime) ? 'inline' : 'attachment'}; filename="download"; filename*=UTF-8''${encodeURIComponent(file.name).replace(/['()*]/g, c => '%' + c.charCodeAt(0).toString(16))}` }); res.sendFile(path.join(DATA, file.id)); }); app.use(express.static(path.join(ROOT, 'public'), { dotfiles: 'deny' })); function cleanup() { for (const file of db.prepare('SELECT id FROM files WHERE expires <= ?').all(Date.now())) { fs.rmSync(path.join(DATA, file.id), { force: true }); db.prepare('DELETE FROM files WHERE id = ?').run(file.id); } for (const name of fs.readdirSync(DATA)) { if (/^(?:[a-z0-9]{5}|[a-f0-9]{48})$/.test(name) && fs.statSync(path.join(DATA, name)).mtimeMs < Date.now() - 86400000 && !db.prepare('SELECT id FROM files WHERE id = ?').get(name)) fs.rmSync(path.join(DATA, name), { force: true }); } } cleanup(); setInterval(cleanup, 60000).unref(); app.listen(8787, '127.0.0.1', () => console.log('Filedrop listening on 8787'));