added admin file browser
This commit is contained in:
@@ -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)}`)}">← 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) {
|
||||
|
||||
Reference in New Issue
Block a user