switched user management to sqlite; better admin file browser; csrf protection for management interface using by csrf tokens
This commit is contained in:
@@ -1,8 +0,0 @@
|
|||||||
# This file stores user logins (and this file only)
|
|
||||||
# There is no other way to add user logins
|
|
||||||
# Comments in this file may only start at the very beginning of a line
|
|
||||||
|
|
||||||
# password is bcrypt of 123456
|
|
||||||
# the format per line is <username>;;<bcrypt hashed password>
|
|
||||||
foo@example.com;;$2a$12$JchPr84/tmKH2muqomK1qe/cj/X0PwcooA5ugynNn3HjU/wpxoNEe
|
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ services:
|
|||||||
- LOGIN_FILE=/app/.logins
|
- LOGIN_FILE=/app/.logins
|
||||||
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
||||||
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
|
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
|
||||||
|
- TRUST_PROXY=true
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/m
|
|||||||
const port = parseInt(process.env.PORT || '3000', 10);
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite');
|
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite');
|
||||||
const loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins');
|
|
||||||
const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
|
const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
|
||||||
const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10);
|
const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10);
|
||||||
const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
|
const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
|
||||||
@@ -41,6 +40,10 @@ app.set('trust proxy', trustProxy);
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const db = new sqlite3.Database(dbPath);
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
@@ -66,6 +69,11 @@ db.serialize(() => {
|
|||||||
)`);
|
)`);
|
||||||
db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
|
db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
|
||||||
db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
|
db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function run(sql, params = []) {
|
function run(sql, params = []) {
|
||||||
@@ -112,6 +120,13 @@ function logEvent(event, owner, detail) {
|
|||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const csrfCookieName = 'csrf';
|
||||||
|
const csrfCookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -121,6 +136,60 @@ function escapeHtml(value) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csrfField(token) {
|
||||||
|
return `<input type="hidden" name="csrfToken" value="${escapeHtml(token)}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCsrfToken(req, res, next) {
|
||||||
|
let token = req.cookies[csrfCookieName];
|
||||||
|
if (!token) {
|
||||||
|
token = crypto.randomBytes(32).toString('hex');
|
||||||
|
res.cookie(csrfCookieName, token, csrfCookieOptions);
|
||||||
|
}
|
||||||
|
res.locals.csrfToken = token;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrigin(req) {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
const referer = req.get('referer');
|
||||||
|
const header = origin || referer;
|
||||||
|
if (!header) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(header);
|
||||||
|
const expected = `${req.protocol}://${req.get('host')}`;
|
||||||
|
return parsed.origin === expected;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfGuard(req, res, next) {
|
||||||
|
if (!isSameOrigin(req)) {
|
||||||
|
if (req.path.startsWith(`${basePath}/api/`)) {
|
||||||
|
res.status(403).json({ error: 'Origin check failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">Origin-Prüfung fehlgeschlagen.</p>'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.cookies[csrfCookieName];
|
||||||
|
const provided = req.body?.csrfToken || req.get('x-csrf-token');
|
||||||
|
if (!token || !provided || token !== provided) {
|
||||||
|
if (req.path.startsWith(`${basePath}/api/`)) {
|
||||||
|
res.status(403).json({ error: 'CSRF token mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">CSRF-Prüfung fehlgeschlagen.</p>'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
const loginAttempts = new Map();
|
const loginAttempts = new Map();
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const LOGIN_MAX_ATTEMPTS = 10;
|
const LOGIN_MAX_ATTEMPTS = 10;
|
||||||
@@ -152,35 +221,9 @@ function clearLoginAttempts(type, req) {
|
|||||||
loginAttempts.delete(`${type}:${ip}`);
|
loginAttempts.delete(`${type}:${ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLogins(contents) {
|
async function getUserHash(username) {
|
||||||
const entries = new Map();
|
const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]);
|
||||||
const lines = contents.split(/\r?\n/);
|
return row ? row.password_hash : null;
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line || line.startsWith('#')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parts = line.split(';;');
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const username = parts[0].trim();
|
|
||||||
const hash = parts[1].trim();
|
|
||||||
if (!username || !hash) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
entries.set(username, hash);
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLogins() {
|
|
||||||
try {
|
|
||||||
const contents = await fs.promises.readFile(loginFile, 'utf8');
|
|
||||||
return parseLogins(contents);
|
|
||||||
} catch (err) {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBase32(buffer) {
|
function toBase32(buffer) {
|
||||||
@@ -274,31 +317,34 @@ function renderFileManagerPage(title, body) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>${title}</title>
|
<title>${title}</title>
|
||||||
<style>
|
<style>
|
||||||
:root { --ink:#0b0f19; --muted:#556070; --line:#dfe4ea; --bg:#f2f4f7; --card:#ffffff; --accent:#0f766e; }
|
:root { --ink:#0b0f19; --muted:#5b6470; --line:#d8dde4; --bg:#eef1f5; --card:#ffffff; --accent:#0f766e; --accent-strong:#0a5b55; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: "Gill Sans", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); }
|
body { margin: 0; font-family: "IBM Plex Sans", "Noto Sans", sans-serif; background: var(--bg); color: var(--ink); }
|
||||||
main { max-width: 1280px; margin: 0 auto; padding: 24px 18px 64px; }
|
main { max-width: 1280px; margin: 0 auto; padding: 26px 18px 70px; }
|
||||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(135deg, #ffffff 0%, #f6fbfa 100%); border: 1px solid var(--line); }
|
||||||
h1 { margin: 0; font-size: 1.7rem; letter-spacing: 0.02em; }
|
h1 { margin: 0; font-size: 1.75rem; letter-spacing: 0.01em; }
|
||||||
h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
h2 { margin: 0 0 12px; font-size: 1.08rem; }
|
||||||
.muted { color: var(--muted); font-size: 0.95rem; }
|
.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); }
|
.card { margin-top: 18px; padding: 16px; background: var(--card); border-radius: 16px; border: 1px solid var(--line); box-shadow: 0 8px 26px rgba(12, 18, 28, 0.08); }
|
||||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
.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; }
|
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px; border-radius: 999px; background: #f1f5f9; border: 1px solid var(--line); color: var(--ink); text-decoration: none; font-weight: 600; }
|
||||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
.tag span { color: var(--muted); font-weight: 500; }
|
||||||
|
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
||||||
form { display: grid; gap: 10px; }
|
form { display: grid; gap: 10px; }
|
||||||
label { display: grid; gap: 6px; font-weight: 600; }
|
label { display: grid; gap: 6px; font-weight: 600; }
|
||||||
input, button, select { font: inherit; padding: 8px 10px; border-radius: 8px; }
|
input, button, select { font: inherit; padding: 9px 12px; border-radius: 10px; }
|
||||||
input, select { border: 1px solid var(--line); background: #fff; }
|
input, select { border: 1px solid var(--line); background: #fff; }
|
||||||
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
|
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; transition: transform 0.15s ease, background 0.2s ease; }
|
||||||
|
button:hover { background: var(--accent-strong); }
|
||||||
button.secondary { background: transparent; color: var(--accent); }
|
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; }
|
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; }
|
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
.name { display: inline-flex; align-items: center; gap: 8px; }
|
tbody tr:nth-child(even) { background: #f9fbfc; }
|
||||||
.folder { font-weight: 700; }
|
.name { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.name strong { font-weight: 700; }
|
||||||
|
.folder { font-weight: 700; color: var(--accent-strong); text-decoration: none; }
|
||||||
.actions { display: grid; gap: 6px; }
|
.actions { display: grid; gap: 6px; }
|
||||||
.actions input, .actions button { font-size: 0.9rem; padding: 6px 8px; }
|
.actions input, .actions button { font-size: 0.9rem; padding: 7px 10px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -438,6 +484,15 @@ async function cleanupExpired() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.use(ensureCsrfToken);
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
||||||
|
csrfGuard(req, res, next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const cleanupTimer = setInterval(() => {
|
const cleanupTimer = setInterval(() => {
|
||||||
cleanupExpired().catch(() => undefined);
|
cleanupExpired().catch(() => undefined);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
@@ -467,6 +522,7 @@ app.get(`${basePath}/login`, (req, res) => {
|
|||||||
</header>
|
</header>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<form method="post" action="${baseUrl('/login')}">
|
<form method="post" action="${baseUrl('/login')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
<input name="username" autocomplete="username" required />
|
<input name="username" autocomplete="username" required />
|
||||||
@@ -485,8 +541,7 @@ app.get(`${basePath}/login`, (req, res) => {
|
|||||||
app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
|
app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
|
||||||
const username = String(req.body.username || '').trim();
|
const username = String(req.body.username || '').trim();
|
||||||
const password = String(req.body.password || '');
|
const password = String(req.body.password || '');
|
||||||
const logins = await loadLogins();
|
const hash = await getUserHash(username);
|
||||||
const hash = logins.get(username);
|
|
||||||
|
|
||||||
if (!hash || !bcrypt.compareSync(password, hash)) {
|
if (!hash || !bcrypt.compareSync(password, hash)) {
|
||||||
const body = `
|
const body = `
|
||||||
@@ -499,6 +554,7 @@ app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="pill">Anmeldung fehlgeschlagen</div>
|
<div class="pill">Anmeldung fehlgeschlagen</div>
|
||||||
<form method="post" action="${baseUrl('/login')}">
|
<form method="post" action="${baseUrl('/login')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
<input name="username" autocomplete="username" required />
|
<input name="username" autocomplete="username" required />
|
||||||
@@ -551,6 +607,7 @@ app.get(`${basePath}/admin`, async (req, res) => {
|
|||||||
</header>
|
</header>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<form method="post" action="${baseUrl('/admin/login')}">
|
<form method="post" action="${baseUrl('/admin/login')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<label>
|
<label>
|
||||||
Admin-Passwort
|
Admin-Passwort
|
||||||
<input type="password" name="password" autocomplete="current-password" required />
|
<input type="password" name="password" autocomplete="current-password" required />
|
||||||
@@ -579,6 +636,7 @@ app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) =>
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="pill">Anmeldung fehlgeschlagen</div>
|
<div class="pill">Anmeldung fehlgeschlagen</div>
|
||||||
<form method="post" action="${baseUrl('/admin/login')}">
|
<form method="post" action="${baseUrl('/admin/login')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<label>
|
<label>
|
||||||
Admin-Passwort
|
Admin-Passwort
|
||||||
<input type="password" name="password" autocomplete="current-password" required />
|
<input type="password" name="password" autocomplete="current-password" required />
|
||||||
@@ -665,9 +723,11 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="${baseUrl(`/admin/files/${item.id}/delete`)}">
|
<form method="post" action="${baseUrl(`/admin/files/${item.id}/delete`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<button type="submit" class="secondary">Löschen</button>
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="${baseUrl(`/admin/files/${item.id}/extend`)}">
|
<form method="post" action="${baseUrl(`/admin/files/${item.id}/extend`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
||||||
<button type="submit">Verlängern</button>
|
<button type="submit">Verlängern</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -684,7 +744,9 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="tag" href="${baseUrl('/admin/files')}">Dateimanager</a>
|
<a class="tag" href="${baseUrl('/admin/files')}">Dateimanager</a>
|
||||||
|
<a class="tag" href="${baseUrl('/admin/users')}">Benutzer verwalten</a>
|
||||||
<form method="post" action="${baseUrl('/admin/logout')}">
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<button type="submit" class="secondary">Abmelden</button>
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,6 +795,130 @@ 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/users`, requireAdminPage, async (req, res) => {
|
||||||
|
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
|
||||||
|
|
||||||
|
const rows = users.map((user) => {
|
||||||
|
const username = escapeHtml(user.username);
|
||||||
|
const encoded = encodeURIComponent(user.username);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${username}</td>
|
||||||
|
<td>${formatTimestamp(user.created_at)}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="${baseUrl(`/admin/users/${encoded}/reset`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
|
<input name="password" type="password" placeholder="Neues Passwort" required />
|
||||||
|
<button type="submit">Passwort setzen</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="${baseUrl(`/admin/users/${encoded}/delete`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Benutzer verwalten</h1>
|
||||||
|
<div class="muted">Zugänge im System verwalten.</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
|
||||||
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Neuen Benutzer anlegen</h2>
|
||||||
|
<form method="post" action="${baseUrl('/admin/users/create')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input name="username" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input name="password" type="password" autocomplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Erstellen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Benutzerliste</h2>
|
||||||
|
${users.length ? `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzername</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : '<div class="muted">Keine Benutzer vorhanden.</div>'}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.send(renderPage('Benutzer verwalten', body, 'wide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(`${basePath}/admin/users/create`, requireAdminPage, async (req, res) => {
|
||||||
|
const username = String(req.body.username || '').trim();
|
||||||
|
const password = String(req.body.password || '');
|
||||||
|
if (!username || username.length > 200 || !password) {
|
||||||
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
try {
|
||||||
|
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
|
||||||
|
username,
|
||||||
|
hash,
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Benutzername existiert bereits.</p>', 'wide'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await logEvent('admin_user_create', 'admin', { username });
|
||||||
|
res.redirect(baseUrl('/admin/users'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(`${basePath}/admin/users/:username/reset`, requireAdminPage, async (req, res) => {
|
||||||
|
const username = decodeURIComponent(req.params.username || '');
|
||||||
|
const password = String(req.body.password || '');
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
await run('UPDATE users SET password_hash = ? WHERE username = ?', [hash, username]);
|
||||||
|
await logEvent('admin_user_reset', 'admin', { username });
|
||||||
|
res.redirect(baseUrl('/admin/users'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(`${basePath}/admin/users/:username/delete`, requireAdminPage, async (req, res) => {
|
||||||
|
const username = decodeURIComponent(req.params.username || '');
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await run('DELETE FROM users WHERE username = ?', [username]);
|
||||||
|
await logEvent('admin_user_delete', 'admin', { username });
|
||||||
|
res.redirect(baseUrl('/admin/users'));
|
||||||
|
});
|
||||||
|
|
||||||
app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
||||||
const relativePath = String(req.query.path || '').replace(/^\/+/, '');
|
const relativePath = String(req.query.path || '').replace(/^\/+/, '');
|
||||||
const resolved = resolveAdminPath(relativePath);
|
const resolved = resolveAdminPath(relativePath);
|
||||||
@@ -763,18 +949,20 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="name">
|
<span class="name">
|
||||||
${isDir ? '[DIR]' : '[FILE]'}
|
${isDir ? '<strong>DIR</strong>' : 'FILE'}
|
||||||
${isDir ? `<a class="folder" href="${href}">${escapedName}</a>` : escapedName}
|
${isDir ? `<a class="folder" href="${href}">${escapedName}</a>` : escapedName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>${isDir ? 'Ordner' : 'Datei'}</td>
|
<td>${isDir ? 'Ordner' : 'Datei'}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="${baseUrl('/admin/files/rename')}">
|
<form method="post" action="${baseUrl('/admin/files/rename')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input type="hidden" name="path" value="${escapedPath}" />
|
<input type="hidden" name="path" value="${escapedPath}" />
|
||||||
<input name="newName" placeholder="Neuer Name" required />
|
<input name="newName" placeholder="Neuer Name" required />
|
||||||
<button type="submit">Umbenennen</button>
|
<button type="submit">Umbenennen</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="${baseUrl('/admin/files/delete')}">
|
<form method="post" action="${baseUrl('/admin/files/delete')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input type="hidden" name="path" value="${escapedPath}" />
|
<input type="hidden" name="path" value="${escapedPath}" />
|
||||||
<button type="submit" class="secondary">Löschen</button>
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -797,6 +985,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
|
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
|
||||||
<form method="post" action="${baseUrl('/admin/logout')}">
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<button type="submit" class="secondary">Abmelden</button>
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -804,7 +993,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="tag">Pfad: /${escapeHtml(relativePath || '')}</span>
|
<span class="tag">Pfad <span>/${escapeHtml(relativePath || '')}</span></span>
|
||||||
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">← Zurück</a>` : ''}
|
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">← Zurück</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -813,6 +1002,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Ordner erstellen</h2>
|
<h2>Ordner erstellen</h2>
|
||||||
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
|
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
||||||
<label>
|
<label>
|
||||||
Ordnername
|
Ordnername
|
||||||
@@ -824,6 +1014,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Datei hochladen</h2>
|
<h2>Datei hochladen</h2>
|
||||||
<form method="post" action="${baseUrl('/admin/files/upload')}" enctype="multipart/form-data">
|
<form method="post" action="${baseUrl('/admin/files/upload')}" enctype="multipart/form-data">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
||||||
<label>
|
<label>
|
||||||
Datei
|
Datei
|
||||||
@@ -1005,9 +1196,11 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button>
|
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button>
|
||||||
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
|
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<button type="submit" class="secondary">Löschen</button>
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
|
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
||||||
<button type="submit">Verlängern</button>
|
<button type="submit">Verlängern</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1023,6 +1216,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
|
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="${baseUrl('/logout')}">
|
<form method="post" action="${baseUrl('/logout')}">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<button type="submit" class="secondary">Abmelden</button>
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
@@ -1030,6 +1224,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Datei hochladen</h2>
|
<h2>Datei hochladen</h2>
|
||||||
<form id="upload-form">
|
<form id="upload-form">
|
||||||
|
${csrfField(res.locals.csrfToken)}
|
||||||
<label>
|
<label>
|
||||||
Datei
|
Datei
|
||||||
<input type="file" name="file" required />
|
<input type="file" name="file" required />
|
||||||
|
|||||||
Reference in New Issue
Block a user