drop/server.js

109 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'));