91 lines
5.5 KiB
JavaScript
91 lines
5.5 KiB
JavaScript
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) }); });
|
||
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'));
|