added admin file browser

This commit is contained in:
Ludwig Lehnert
2026-01-12 18:43:56 +01:00
parent 3b4e389512
commit d2348a4875

View File

@@ -224,6 +224,49 @@ function formatCountdown(ts) {
return `${minutes}m`;
}
function renderFileManagerPage(title, body) {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root { --ink:#0b0f19; --muted:#556070; --line:#dfe4ea; --bg:#f2f4f7; --card:#ffffff; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Gill Sans", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); }
main { max-width: 1280px; margin: 0 auto; padding: 24px 18px 64px; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
h1 { margin: 0; font-size: 1.7rem; letter-spacing: 0.02em; }
h2 { margin: 0 0 12px; font-size: 1.1rem; }
.muted { color: var(--muted); font-size: 0.95rem; }
.card { margin-top: 16px; padding: 16px; background: var(--card); border-radius: 14px; border: 1px solid var(--line); }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border-radius: 999px; background: #e8f0f2; color: var(--ink); text-decoration: none; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, button, select { font: inherit; padding: 8px 10px; border-radius: 8px; }
input, select { border: 1px solid var(--line); background: #fff; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button.secondary { background: transparent; color: var(--accent); }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border-radius: 999px; background: #f1f5f9; border: 1px solid var(--line); color: var(--ink); text-decoration: none; }
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
.name { display: inline-flex; align-items: center; gap: 8px; }
.folder { font-weight: 700; }
.actions { display: grid; gap: 6px; }
.actions input, .actions button { font-size: 0.9rem; padding: 6px 8px; }
</style>
</head>
<body>
<main>
${body}
</main>
</body>
</html>`;
}
function renderPage(title, body, mainClass = '') {
return `<!doctype html>
<html lang="de">
@@ -307,6 +350,23 @@ function requireAdminPage(req, res, next) {
next();
}
function isAllowedAdminPath(relativePath) {
const parts = relativePath.split('/').filter(Boolean);
return !parts.includes('_share');
}
function resolveAdminPath(relativePath) {
const cleaned = relativePath.replace(/\\/g, '/');
if (!isAllowedAdminPath(cleaned)) {
return null;
}
const target = path.resolve(dataDir, cleaned);
if (target === dataDir || target.startsWith(`${dataDir}${path.sep}`)) {
return target;
}
return null;
}
function requireAuthApi(req, res, next) {
const user = getUserFromRequest(req);
if (!user) {
@@ -577,9 +637,12 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<h1>Adminübersicht</h1>
<div class="muted">Systemstatistiken und Logs</div>
</div>
<form method="post" action="${baseUrl('/admin/logout')}">
<button type="submit" class="secondary">Abmelden</button>
</form>
<div class="toolbar">
<a class="tag" href="${baseUrl('/admin/files')}">Dateimanager</a>
<form method="post" action="${baseUrl('/admin/logout')}">
<button type="submit" class="secondary">Abmelden</button>
</form>
</div>
</header>
<section class="card">
<h2>Statistiken</h2>
@@ -625,6 +688,218 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
res.send(renderPage('Adminübersicht', body, 'wide'));
});
app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
const relativePath = String(req.query.path || '').replace(/^\/+/, '');
const resolved = resolveAdminPath(relativePath);
if (!resolved) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
let entries;
try {
entries = await fs.promises.readdir(resolved, { withFileTypes: true });
} catch (err) {
res.status(500).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ordner kann nicht gelesen werden.</p>'));
return;
}
const parentPath = relativePath ? relativePath.split('/').slice(0, -1).join('/') : '';
const dirs = entries.filter((entry) => entry.isDirectory() && entry.name !== '_share');
const files = entries.filter((entry) => !entry.isDirectory() && entry.name !== '_share');
const rowForEntry = (entry, isDir) => {
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`);
return `
<tr>
<td>
<span class="name">
${isDir ? '[DIR]' : '[FILE]'}
${isDir ? `<a class="folder" href="${href}">${entry.name}</a>` : entry.name}
</span>
</td>
<td>${isDir ? 'Ordner' : 'Datei'}</td>
<td class="actions">
<form method="post" action="${baseUrl('/admin/files/rename')}">
<input type="hidden" name="path" value="${childPath}" />
<input name="newName" placeholder="Neuer Name" required />
<button type="submit">Umbenennen</button>
</form>
<form method="post" action="${baseUrl('/admin/files/delete')}">
<input type="hidden" name="path" value="${childPath}" />
<button type="submit" class="secondary">Löschen</button>
</form>
</td>
</tr>
`;
};
const tableRows = [
...dirs.map((entry) => rowForEntry(entry, true)),
...files.map((entry) => rowForEntry(entry, false)),
].join('');
const body = `
<header>
<div>
<h1>Admin-Dateimanager</h1>
<div class="muted">Verwalten aller Dateien (außer _share).</div>
</div>
<div class="toolbar">
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
<form method="post" action="${baseUrl('/admin/logout')}">
<button type="submit" class="secondary">Abmelden</button>
</form>
</div>
</header>
<section class="card">
<div class="toolbar">
<span class="tag">Pfad: /${relativePath || ''}</span>
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">&larr; Zurück</a>` : ''}
</div>
</section>
<section class="card grid">
<div>
<h2>Ordner erstellen</h2>
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
<input type="hidden" name="path" value="${relativePath}" />
<label>
Ordnername
<input name="name" placeholder="z.B. Projekte" required />
</label>
<button type="submit">Erstellen</button>
</form>
</div>
<div>
<h2>Datei hochladen</h2>
<form method="post" action="${baseUrl('/admin/files/upload')}" enctype="multipart/form-data">
<input type="hidden" name="path" value="${relativePath}" />
<label>
Datei
<input type="file" name="file" required />
</label>
<button type="submit">Hochladen</button>
</form>
</div>
</section>
<section class="card">
<h2>Inhalt</h2>
${tableRows ? `
<table>
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
` : '<div class="muted">Keine Eintraege in diesem Ordner.</div>'}
</section>
`;
res.send(renderFileManagerPage('Admin-Dateien', body));
});
app.post(`${basePath}/admin/files/mkdir`, requireAdminPage, async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
const name = String(req.body.name || '').trim();
if (!name || name.includes('/') || name.includes('\\') || name === '_share') {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Ordnername.</p>'));
return;
}
const base = resolveAdminPath(relativePath);
if (!base) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
const target = path.join(base, name);
await fs.promises.mkdir(target, { recursive: true });
await logEvent('admin_mkdir', 'admin', { path: path.join(relativePath, name) });
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
});
app.post(`${basePath}/admin/files/upload`, requireAdminPage, upload.single('file'), async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
const base = resolveAdminPath(relativePath);
if (!base) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
if (!req.file) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Keine Datei hochgeladen.</p>'));
return;
}
const filename = path.basename(req.file.originalname);
if (filename === '_share') {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Dateiname.</p>'));
return;
}
const target = path.join(base, filename);
try {
await fs.promises.rename(req.file.path, target);
} catch (err) {
if (err.code === 'EXDEV') {
await fs.promises.copyFile(req.file.path, target);
await fs.promises.unlink(req.file.path);
} else {
throw err;
}
}
await logEvent('admin_upload', 'admin', { path: path.join(relativePath, filename) });
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
});
app.post(`${basePath}/admin/files/rename`, requireAdminPage, async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
const newName = String(req.body.newName || '').trim();
if (!relativePath) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Root kann nicht umbenannt werden.</p>'));
return;
}
if (!newName || newName.includes('/') || newName.includes('\\') || newName === '_share') {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger neuer Name.</p>'));
return;
}
const resolved = resolveAdminPath(relativePath);
if (!resolved) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
const target = path.join(path.dirname(resolved), newName);
await fs.promises.rename(resolved, target);
await logEvent('admin_rename', 'admin', { from: relativePath, to: path.join(path.dirname(relativePath), newName) });
const parent = path.dirname(relativePath);
const nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
});
app.post(`${basePath}/admin/files/delete`, requireAdminPage, async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Root kann nicht gelöscht werden.</p>'));
return;
}
const resolved = resolveAdminPath(relativePath);
if (!resolved) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
await fs.promises.rm(resolved, { recursive: true, force: true });
await logEvent('admin_delete', 'admin', { path: relativePath });
const parent = path.dirname(relativePath);
const nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
});
app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, async (req, res) => {
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [req.params.id]);
if (!uploadEntry) {