DodoTransfer/lib/admin.js
2026-11-05 10:28:10 +09:00

167 lines
6.0 KiB
JavaScript

'use strict';
const express = require('express');
const config = require('../config');
const auth = require('./auth');
const settings = require('./settings');
const mime = require('./mime');
const utils = require('./utils');
const { pipeArchive } = require('./archive');
/**
* Admin area: manage the uploaded files and the settings (name, logo).
* Mounted at <baseUrl>admin, only available when adminPass is set.
*/
module.exports = function createAdmin({ db, store, renderPage }) {
const router = express.Router();
// not configured: there is no admin area
router.use((req, res, next) => config.adminPass ? next() : next('router'));
router.get('/', (req, res) => {
res.send(renderPage(req, 'admin'));
});
const guard = (req, res, next) => auth.isAdmin(req) ? next() : auth.deny(req, res);
function fileInfo(sid, file) {
const m = file.metadata;
const oneTime = m.retention === 'one-time';
return {
key: file.key,
name: m.name,
comment: m.comment || '',
size: file.isPartial ? +m.uploadLength : file.size,
// unfinished uploads, they use disk space as well
partial: !!file.isPartial,
retention: m.retention,
createdAt: +m.createdAt,
lastDownload: m.lastDownload ? +m.lastDownload : null,
hasPassword: !!m.password,
internalBypass: m.internalBypass === 'true',
expiresAt: db.expiresAt(file),
// previews of one-time files would use them up
previewType: oneTime || file.isPartial ? null : mime.previewKind(m.name),
url: `${ config.baseUrl }files/${ sid }++${ file.key }`,
thumbUrl: m.thumb && !oneTime ? `${ config.baseUrl }files/${ sid }++${ file.key }.thumb` : null,
};
}
router.get('/data.json', guard, (req, res) => {
const buckets = Object.keys(db.db)
.map(sid => ({ sid, files: db.db[sid].map(file => fileInfo(sid, file)) }))
.filter(bucket => bucket.files.length > 0);
res.set('Cache-Control', 'no-store');
res.json({
buckets,
now: Date.now(),
totalSize: buckets.reduce((sum, b) => sum + b.files.reduce((s, f) => s + (f.size || 0), 0), 0),
});
});
// Browsers can not send a header with a plain download link, so the page asks
// for a link which is valid for one minute.
router.post('/download-link', guard, (req, res) => {
const { sid, key, format } = req.body || {};
if (!utils.isSafeBucketFid(sid)) return res.status(400).json({ message: 'Invalid bucket id' });
const files = (db.get(sid) || []).filter(file => !file.isPartial);
if (!files.length) return res.status(404).json({ message: 'Bucket not found' });
if (key) {
if (!files.some(file => file.key === key)) return res.status(404).json({ message: 'File not found' });
} else if (!['zip', 'tar.gz'].includes(format)) {
return res.status(400).json({ message: 'Invalid format' });
}
const { token } = auth.issue({ sid, key: key || null, format: key ? null : format }, 60, 'admin-download');
res.json({ url: `${ config.baseUrl }admin/dl?t=${ encodeURIComponent(token) }` });
});
// Downloads of the admin neither consume one-time files nor count as download
router.get('/dl', (req, res) => {
const data = auth.verify(String(req.query.t || ''), 'admin-download');
if (!data) return res.status(403).send('Link expired');
const files = (db.get(data.sid) || []).filter(file => !file.isPartial);
res.set('Cache-Control', 'no-store, no-transform');
if (data.key) {
const file = files.find(f => f.key === data.key);
if (!file) return res.status(404).send('File not found');
res.set('Content-Disposition', utils.contentDisposition(file.metadata.name, file.key));
return res.sendFile(store.getFilename(`${ data.sid }++${ file.key }`), err => {
if (err && !res.headersSent) res.status(404).end();
});
}
if (!files.length) return res.status(404).send('Bucket not found');
res.set('Content-Disposition', utils.contentDisposition(`${ data.sid }.${ data.format }`, 'files'));
return pipeArchive(res, files, data.format, store);
});
router.delete('/buckets/:sid', guard, async (req, res, next) => {
try {
if (!utils.isSafeBucketFid(req.params.sid)) return res.status(400).json({ message: 'Invalid bucket id' });
const removed = await db.removeBucket(req.params.sid);
if (!removed) return res.status(404).json({ message: 'Bucket not found' });
res.json({ removed });
} catch (e) {
next(e);
}
});
router.delete('/buckets/:sid/files/:key', guard, async (req, res, next) => {
try {
const { sid, key } = req.params;
if (!utils.isSafeBucketFid(sid) || !utils.UUID_V4_RE.test(key)) {
return res.status(400).json({ message: 'Invalid id' });
}
if (!(db.get(sid) || []).some(file => file.key === key)) {
return res.status(404).json({ message: 'File not found' });
}
await db.remove(sid, key);
res.json({ removed: 1 });
} catch (e) {
next(e);
}
});
// Settings: name, logo, footer
router.get('/settings', guard, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json({ brand: settings.brand(), defaults: settings.defaults(), maxLogoSize: settings.MAX_LOGO_SIZE });
});
router.put('/settings', guard, (req, res, next) => {
try {
res.json({ brand: settings.update(req.body || {}) });
} catch (e) {
next(e);
}
});
router.post('/settings/logo', guard,
express.raw({ type: () => true, limit: settings.MAX_LOGO_SIZE }),
(req, res, next) => {
try {
res.json({ brand: settings.setLogo(req.body) });
} catch (e) {
next(e);
}
}
);
router.delete('/settings/logo', guard, (req, res, next) => {
try {
res.json({ brand: settings.clearLogo() });
} catch (e) {
next(e);
}
});
router.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.status || err.statusCode || 500;
if (status >= 500) console.error(err);
res.status(status).json({ message: status >= 500 ? 'Internal error' : err.message });
});
return router;
};