added admin file browser
This commit is contained in:
@@ -224,6 +224,49 @@ function formatCountdown(ts) {
|
|||||||
return `${minutes}m`;
|
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 = '') {
|
function renderPage(title, body, mainClass = '') {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -307,6 +350,23 @@ function requireAdminPage(req, res, next) {
|
|||||||
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) {
|
function requireAuthApi(req, res, next) {
|
||||||
const user = getUserFromRequest(req);
|
const user = getUserFromRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -577,9 +637,12 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|||||||
<h1>Adminübersicht</h1>
|
<h1>Adminübersicht</h1>
|
||||||
<div class="muted">Systemstatistiken und Logs</div>
|
<div class="muted">Systemstatistiken und Logs</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="${baseUrl('/admin/logout')}">
|
<div class="toolbar">
|
||||||
<button type="submit" class="secondary">Abmelden</button>
|
<a class="tag" href="${baseUrl('/admin/files')}">Dateimanager</a>
|
||||||
</form>
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
||||||
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Statistiken</h2>
|
<h2>Statistiken</h2>
|
||||||
@@ -625,6 +688,218 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|||||||
res.send(renderPage('Adminübersicht', body, 'wide'));
|
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) => {
|
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]);
|
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [req.params.id]);
|
||||||
if (!uploadEntry) {
|
if (!uploadEntry) {
|
||||||
|
|||||||
Reference in New Issue
Block a user