expressjs -> nextjs
This commit is contained in:
758
nextjs/src/lib/actions.js
Normal file
758
nextjs/src/lib/actions.js
Normal file
@@ -0,0 +1,758 @@
|
||||
'use server';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
adminHash,
|
||||
managementBasePath,
|
||||
maxRetentionSeconds,
|
||||
maxUploadBytes,
|
||||
shareDir,
|
||||
uploadTtlSeconds,
|
||||
} from './config.js';
|
||||
import { get, logEvent, run, runCleanupIfNeeded } from './db.js';
|
||||
import {
|
||||
adminFilesHref,
|
||||
isValidNodeName,
|
||||
resolveAdminPath,
|
||||
safeBaseName,
|
||||
sanitizeExtension,
|
||||
sanitizeRelativePath,
|
||||
} from './files.js';
|
||||
import {
|
||||
checkLoginRateLimit,
|
||||
clearAuthCookie,
|
||||
clearLoginRateLimit,
|
||||
getRequestMeta,
|
||||
requireAdminUser,
|
||||
requireAuthenticatedUser,
|
||||
setAuthCookie,
|
||||
verifyCsrf,
|
||||
} from './security.js';
|
||||
|
||||
function buildPathWithQuery(pathname, params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = query.toString();
|
||||
return serialized ? `${pathname}?${serialized}` : pathname;
|
||||
}
|
||||
|
||||
function redirectWithError(pathname, message) {
|
||||
redirect(buildPathWithQuery(pathname, { error: message }));
|
||||
}
|
||||
|
||||
function redirectWithSuccess(pathname, message) {
|
||||
redirect(buildPathWithQuery(pathname, { success: message }));
|
||||
}
|
||||
|
||||
function getUploadId(formData, fieldName = 'uploadId') {
|
||||
const parsed = Number.parseInt(String(formData.get(fieldName) || ''), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseHours(value, fallbackSeconds) {
|
||||
const parsed = Number.parseFloat(String(value || ''));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed * 3600);
|
||||
}
|
||||
return fallbackSeconds;
|
||||
}
|
||||
|
||||
function toBase32(buffer) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = '';
|
||||
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
|
||||
while (bits >= 5) {
|
||||
output += alphabet[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bits > 0) {
|
||||
output += alphabet[(value << (5 - bits)) & 31];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function createRandomId() {
|
||||
return toBase32(crypto.randomBytes(5));
|
||||
}
|
||||
|
||||
function uploadedFileFromForm(formData, fieldName = 'file') {
|
||||
const candidate = formData.get(fieldName);
|
||||
if (!candidate || typeof candidate === 'string') {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.arrayBuffer !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function writeUploadedFile(uploadedFile, targetPath) {
|
||||
try {
|
||||
if (typeof uploadedFile.stream === 'function') {
|
||||
await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath));
|
||||
} else {
|
||||
const buffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
await fs.promises.writeFile(targetPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const declaredSize = Number(uploadedFile.size || 0);
|
||||
if (Number.isFinite(declaredSize) && declaredSize >= 0) {
|
||||
return declaredSize;
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
function parentRelativePath(relativePath) {
|
||||
const parent = path.dirname(relativePath);
|
||||
return parent === '.' ? '' : sanitizeRelativePath(parent);
|
||||
}
|
||||
|
||||
async function resolveMoveCopyTarget(sourcePath, targetBasePath) {
|
||||
let targetPath = targetBasePath;
|
||||
try {
|
||||
const targetStat = await fs.promises.stat(targetBasePath);
|
||||
if (targetStat.isDirectory()) {
|
||||
targetPath = path.join(targetBasePath, path.basename(sourcePath));
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function copyPath(sourcePath, targetPath) {
|
||||
const stat = await fs.promises.stat(sourcePath);
|
||||
await fs.promises.cp(sourcePath, targetPath, {
|
||||
recursive: stat.isDirectory(),
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function userLoginAction(formData) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/login`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
const waitMinutes = await checkLoginRateLimit('user');
|
||||
if (waitMinutes > 0) {
|
||||
redirectWithError(
|
||||
`${managementBasePath}/login`,
|
||||
`Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.`
|
||||
);
|
||||
}
|
||||
|
||||
const username = String(formData.get('username') || '').trim();
|
||||
const password = String(formData.get('password') || '');
|
||||
const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]);
|
||||
|
||||
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
|
||||
redirectWithError(`${managementBasePath}/login`, 'Anmeldung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await setAuthCookie({ sub: username });
|
||||
await clearLoginRateLimit('user');
|
||||
await logEvent('login', username, { ok: true }, await getRequestMeta());
|
||||
redirect(`${managementBasePath}/dashboard`);
|
||||
}
|
||||
|
||||
export async function userLogoutAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await clearAuthCookie();
|
||||
redirect(`${managementBasePath}/login`);
|
||||
}
|
||||
|
||||
export async function adminLoginAction(formData) {
|
||||
if (!adminHash) {
|
||||
redirectWithError(`${managementBasePath}/admin`, 'Admin-Zugang ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
const waitMinutes = await checkLoginRateLimit('admin');
|
||||
if (waitMinutes > 0) {
|
||||
redirectWithError(
|
||||
`${managementBasePath}/admin`,
|
||||
`Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.`
|
||||
);
|
||||
}
|
||||
|
||||
const password = String(formData.get('password') || '');
|
||||
if (!bcrypt.compareSync(password, adminHash)) {
|
||||
redirectWithError(`${managementBasePath}/admin`, 'Anmeldung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await setAuthCookie({ sub: 'admin', admin: true });
|
||||
await clearLoginRateLimit('admin');
|
||||
await logEvent('admin_login', 'admin', { ok: true }, await getRequestMeta());
|
||||
redirect(`${managementBasePath}/admin/dashboard`);
|
||||
}
|
||||
|
||||
export async function adminLogoutAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await clearAuthCookie();
|
||||
redirect(`${managementBasePath}/admin`);
|
||||
}
|
||||
|
||||
export async function uploadFileAction(formData) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
const user = await requireAuthenticatedUser();
|
||||
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Keine Datei hochgeladen.');
|
||||
}
|
||||
|
||||
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||
redirectWithError(
|
||||
`${managementBasePath}/dashboard`,
|
||||
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
||||
const extension = sanitizeExtension(originalName);
|
||||
const storedName = `${createRandomId()}${extension}`;
|
||||
const storedPath = path.join(shareDir, storedName);
|
||||
|
||||
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);
|
||||
const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds);
|
||||
|
||||
try {
|
||||
const sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
|
||||
await run(
|
||||
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
user.username,
|
||||
originalName,
|
||||
storedName,
|
||||
storedPath,
|
||||
sizeBytes,
|
||||
now,
|
||||
now + cappedRetention * 1000,
|
||||
]
|
||||
);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Upload fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'upload',
|
||||
user.username,
|
||||
{ name: storedName, size: Number(uploadedFile.size || 0) },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload abgeschlossen.');
|
||||
}
|
||||
|
||||
export async function deleteOwnUploadAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
const user = await requireAuthenticatedUser();
|
||||
const uploadId = getUploadId(formData);
|
||||
if (!uploadId) {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
|
||||
}
|
||||
|
||||
const uploadEntry = await get(
|
||||
'SELECT id, stored_path FROM uploads WHERE id = ? AND owner = ?',
|
||||
[uploadId, user.username]
|
||||
);
|
||||
|
||||
if (!uploadEntry) {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(uploadEntry.stored_path);
|
||||
} catch {
|
||||
}
|
||||
|
||||
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
|
||||
await logEvent('delete', user.username, { id: uploadEntry.id }, await getRequestMeta());
|
||||
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload gelöscht.');
|
||||
}
|
||||
|
||||
export async function extendOwnUploadAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
const user = await requireAuthenticatedUser();
|
||||
const uploadId = getUploadId(formData);
|
||||
if (!uploadId) {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
|
||||
}
|
||||
|
||||
const uploadEntry = await get(
|
||||
'SELECT id, expires_at FROM uploads WHERE id = ? AND owner = ?',
|
||||
[uploadId, user.username]
|
||||
);
|
||||
|
||||
if (!uploadEntry) {
|
||||
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
|
||||
}
|
||||
|
||||
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
|
||||
const now = Date.now();
|
||||
const baseExpiry = Math.max(uploadEntry.expires_at, now);
|
||||
const maxExpiry = now + maxRetentionSeconds * 1000;
|
||||
const nextExpiry = Math.min(baseExpiry + extensionSeconds * 1000, maxExpiry);
|
||||
|
||||
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]);
|
||||
await logEvent(
|
||||
'extend',
|
||||
user.username,
|
||||
{ id: uploadEntry.id, expires_at: nextExpiry },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.');
|
||||
}
|
||||
|
||||
export async function adminCreateUserAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const username = String(formData.get('username') || '').trim();
|
||||
const password = String(formData.get('password') || '');
|
||||
if (!username || username.length > 200 || !password) {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 12);
|
||||
try {
|
||||
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
|
||||
username,
|
||||
passwordHash,
|
||||
Date.now(),
|
||||
]);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'Benutzername existiert bereits.');
|
||||
}
|
||||
|
||||
await logEvent('admin_user_create', 'admin', { username }, await getRequestMeta());
|
||||
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer erstellt.');
|
||||
}
|
||||
|
||||
export async function adminResetUserAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const username = String(formData.get('username') || '').trim();
|
||||
const password = String(formData.get('password') || '');
|
||||
if (!username || !password) {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 12);
|
||||
await run('UPDATE users SET password_hash = ? WHERE username = ?', [passwordHash, username]);
|
||||
await logEvent('admin_user_reset', 'admin', { username }, await getRequestMeta());
|
||||
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Passwort aktualisiert.');
|
||||
}
|
||||
|
||||
export async function adminDeleteUserAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const username = String(formData.get('username') || '').trim();
|
||||
if (!username) {
|
||||
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
|
||||
}
|
||||
|
||||
await run('DELETE FROM users WHERE username = ?', [username]);
|
||||
await logEvent('admin_user_delete', 'admin', { username }, await getRequestMeta());
|
||||
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer gelöscht.');
|
||||
}
|
||||
|
||||
export async function adminDeleteUploadAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const uploadId = getUploadId(formData);
|
||||
if (!uploadId) {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.');
|
||||
}
|
||||
|
||||
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [uploadId]);
|
||||
if (!uploadEntry) {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(uploadEntry.stored_path);
|
||||
} catch {
|
||||
}
|
||||
|
||||
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
|
||||
await logEvent('delete', 'admin', { id: uploadEntry.id }, await getRequestMeta());
|
||||
redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Upload gelöscht.');
|
||||
}
|
||||
|
||||
export async function adminExtendUploadAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const uploadId = getUploadId(formData);
|
||||
if (!uploadId) {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.');
|
||||
}
|
||||
|
||||
const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ?', [uploadId]);
|
||||
if (!uploadEntry) {
|
||||
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.');
|
||||
}
|
||||
|
||||
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
|
||||
const now = Date.now();
|
||||
const baseExpiry = Math.max(uploadEntry.expires_at, now);
|
||||
const maxExpiry = now + maxRetentionSeconds * 1000;
|
||||
const nextExpiry = Math.min(baseExpiry + extensionSeconds * 1000, maxExpiry);
|
||||
|
||||
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]);
|
||||
await logEvent(
|
||||
'extend',
|
||||
'admin',
|
||||
{ id: uploadEntry.id, expires_at: nextExpiry },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Aufbewahrung aktualisiert.');
|
||||
}
|
||||
|
||||
export async function adminMkdirAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||
const folderName = String(formData.get('name') || '').trim();
|
||||
|
||||
if (!isValidNodeName(folderName)) {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Ordnername.');
|
||||
}
|
||||
|
||||
const basePath = resolveAdminPath(relativePath);
|
||||
if (!basePath) {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
const targetPath = path.join(basePath, folderName);
|
||||
try {
|
||||
await fs.promises.mkdir(targetPath, { recursive: true });
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Ordner konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'admin_mkdir',
|
||||
'admin',
|
||||
{ path: sanitizeRelativePath(path.join(relativePath, folderName)) },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(adminFilesHref(relativePath), 'Ordner erstellt.');
|
||||
}
|
||||
|
||||
export async function adminUploadToPathAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||
const basePath = resolveAdminPath(relativePath);
|
||||
if (!basePath) {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Keine Datei hochgeladen.');
|
||||
}
|
||||
|
||||
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||
redirectWithError(
|
||||
adminFilesHref(relativePath),
|
||||
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = safeBaseName(uploadedFile.name, 'upload');
|
||||
if (!isValidNodeName(fileName)) {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Dateiname.');
|
||||
}
|
||||
|
||||
try {
|
||||
await writeUploadedFile(uploadedFile, path.join(basePath, fileName));
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(relativePath), 'Datei konnte nicht hochgeladen werden.');
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'admin_upload',
|
||||
'admin',
|
||||
{ path: sanitizeRelativePath(path.join(relativePath, fileName)) },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(adminFilesHref(relativePath), 'Datei hochgeladen.');
|
||||
}
|
||||
|
||||
export async function adminRenamePathAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||
const newName = String(formData.get('newName') || '').trim();
|
||||
|
||||
if (!relativePath) {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'Root kann nicht umbenannt werden.');
|
||||
}
|
||||
|
||||
if (!isValidNodeName(newName)) {
|
||||
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Ungültiger Name.');
|
||||
}
|
||||
|
||||
const sourcePath = resolveAdminPath(relativePath);
|
||||
if (!sourcePath) {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
const targetPath = path.join(path.dirname(sourcePath), newName);
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht umbenannt werden.');
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'admin_rename',
|
||||
'admin',
|
||||
{
|
||||
from: relativePath,
|
||||
to: sanitizeRelativePath(path.join(path.dirname(relativePath), newName)),
|
||||
},
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad umbenannt.');
|
||||
}
|
||||
|
||||
export async function adminDeletePathAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||
if (!relativePath) {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'Root kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
const sourcePath = resolveAdminPath(relativePath);
|
||||
if (!sourcePath) {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(sourcePath, { recursive: true, force: true });
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
await logEvent('admin_delete', 'admin', { path: relativePath }, await getRequestMeta());
|
||||
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad gelöscht.');
|
||||
}
|
||||
|
||||
export async function adminMovePathAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const currentPath = sanitizeRelativePath(formData.get('currentPath'));
|
||||
const sourceRelativePath = sanitizeRelativePath(formData.get('path'));
|
||||
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
|
||||
|
||||
if (!sourceRelativePath || !targetRelativePath) {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
|
||||
}
|
||||
|
||||
const sourcePath = resolveAdminPath(sourceRelativePath);
|
||||
const targetBasePath = resolveAdminPath(targetRelativePath);
|
||||
if (!sourcePath || !targetBasePath) {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
|
||||
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'EXDEV') {
|
||||
try {
|
||||
await copyPath(sourcePath, targetPath);
|
||||
await fs.promises.rm(sourcePath, { recursive: true, force: true });
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
|
||||
}
|
||||
} else {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
|
||||
}
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'admin_move',
|
||||
'admin',
|
||||
{ from: sourceRelativePath, to: targetRelativePath },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad verschoben.');
|
||||
}
|
||||
|
||||
export async function adminCopyPathAction(formData) {
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
|
||||
const currentPath = sanitizeRelativePath(formData.get('currentPath'));
|
||||
const sourceRelativePath = sanitizeRelativePath(formData.get('path'));
|
||||
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
|
||||
|
||||
if (!sourceRelativePath || !targetRelativePath) {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
|
||||
}
|
||||
|
||||
const sourcePath = resolveAdminPath(sourceRelativePath);
|
||||
const targetBasePath = resolveAdminPath(targetRelativePath);
|
||||
if (!sourcePath || !targetBasePath) {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
|
||||
}
|
||||
|
||||
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
|
||||
|
||||
try {
|
||||
await copyPath(sourcePath, targetPath);
|
||||
} catch {
|
||||
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht kopiert werden.');
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
'admin_copy',
|
||||
'admin',
|
||||
{ from: sourceRelativePath, to: targetRelativePath },
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad kopiert.');
|
||||
}
|
||||
16
nextjs/src/lib/config.js
Normal file
16
nextjs/src/lib/config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path';
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export const managementBasePath = '/manage';
|
||||
export const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
export const dbPath = process.env.DB_PATH || path.join(dataDir, 'uploads.sqlite');
|
||||
export const shareDir = path.join(dataDir, '_share');
|
||||
export const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
|
||||
export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '604800', 604800);
|
||||
export const maxRetentionSeconds = 90 * 24 * 60 * 60;
|
||||
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0);
|
||||
export const cookieSecure = process.env.COOKIE_SECURE === 'true';
|
||||
146
nextjs/src/lib/db.js
Normal file
146
nextjs/src/lib/db.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sqlite3 from 'sqlite3';
|
||||
|
||||
import { dbPath, shareDir } from './config.js';
|
||||
|
||||
sqlite3.verbose();
|
||||
|
||||
const state = globalThis.__filesLehnertDbState || (globalThis.__filesLehnertDbState = {});
|
||||
|
||||
if (!state.db) {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
fs.mkdirSync(shareDir, { recursive: true });
|
||||
|
||||
state.db = new sqlite3.Database(dbPath);
|
||||
state.cleanupInFlight = false;
|
||||
state.lastCleanupAt = 0;
|
||||
|
||||
initializeSchema(state.db);
|
||||
}
|
||||
|
||||
const db = state.db;
|
||||
|
||||
function initializeSchema(database) {
|
||||
database.serialize(() => {
|
||||
database.run(`CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
downloads INTEGER DEFAULT 0
|
||||
)`);
|
||||
database.run('CREATE INDEX IF NOT EXISTS uploads_owner_idx ON uploads(owner)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)');
|
||||
database.run(`CREATE TABLE IF NOT EXISTS admin_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event TEXT NOT NULL,
|
||||
owner TEXT,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
)`);
|
||||
database.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
|
||||
database.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`);
|
||||
database.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', () => undefined);
|
||||
database.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', () => undefined);
|
||||
database.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', () => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function logEvent(event, owner, detail, requestMeta = {}) {
|
||||
const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {});
|
||||
const ip = requestMeta.ip || null;
|
||||
const userAgent = requestMeta.userAgent || null;
|
||||
|
||||
return run(
|
||||
'INSERT INTO admin_logs (event, owner, detail, created_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[event, owner || null, payload, Date.now(), ip, userAgent]
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function cleanupExpiredUploads() {
|
||||
const now = Date.now();
|
||||
const expired = await all('SELECT id, stored_path FROM uploads WHERE expires_at <= ?', [now]);
|
||||
let removed = 0;
|
||||
|
||||
for (const entry of expired) {
|
||||
try {
|
||||
await fs.promises.unlink(entry.stored_path);
|
||||
} catch {
|
||||
}
|
||||
await run('DELETE FROM uploads WHERE id = ?', [entry.id]);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
await logEvent('cleanup', null, { removed });
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function runCleanupIfNeeded() {
|
||||
const now = Date.now();
|
||||
if (state.cleanupInFlight || now - state.lastCleanupAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.cleanupInFlight = true;
|
||||
state.lastCleanupAt = now;
|
||||
|
||||
try {
|
||||
await cleanupExpiredUploads();
|
||||
} finally {
|
||||
state.cleanupInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
runCleanupIfNeeded().catch(() => undefined);
|
||||
62
nextjs/src/lib/files.js
Normal file
62
nextjs/src/lib/files.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { dataDir, managementBasePath } from './config.js';
|
||||
|
||||
export function sanitizeRelativePath(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function isAllowedAdminPath(relativePath) {
|
||||
const parts = sanitizeRelativePath(relativePath).split('/').filter(Boolean);
|
||||
return !parts.includes('_share');
|
||||
}
|
||||
|
||||
export function resolveAdminPath(relativePath) {
|
||||
const clean = sanitizeRelativePath(relativePath);
|
||||
if (!isAllowedAdminPath(clean)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = path.resolve(dataDir, clean);
|
||||
if (target === dataDir || target.startsWith(`${dataDir}${path.sep}`)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isValidNodeName(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed === '_share') {
|
||||
return false;
|
||||
}
|
||||
return !trimmed.includes('/') && !trimmed.includes('\\');
|
||||
}
|
||||
|
||||
export function safeBaseName(value, fallback = 'file') {
|
||||
const base = path.basename(String(value || '')).trim();
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
export function sanitizeExtension(value) {
|
||||
const extension = path.extname(String(value || '')).toLowerCase();
|
||||
if (!extension) {
|
||||
return '';
|
||||
}
|
||||
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : '';
|
||||
}
|
||||
|
||||
export function adminFilesHref(relativePath = '') {
|
||||
const clean = sanitizeRelativePath(relativePath);
|
||||
if (!clean) {
|
||||
return `${managementBasePath}/admin/files`;
|
||||
}
|
||||
return `${managementBasePath}/admin/files?path=${encodeURIComponent(clean)}`;
|
||||
}
|
||||
70
nextjs/src/lib/format.js
Normal file
70
nextjs/src/lib/format.js
Normal file
@@ -0,0 +1,70 @@
|
||||
export function formatBytes(bytes) {
|
||||
const size = Number(bytes) || 0;
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = size / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatTimestamp(timestamp) {
|
||||
const value = Number(timestamp);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(value).toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
export function formatCountdown(timestamp) {
|
||||
const expiresAt = Number(timestamp) || 0;
|
||||
const deltaMinutes = Math.floor(Math.max(0, expiresAt - Date.now()) / 60_000);
|
||||
const hours = Math.floor(deltaMinutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${deltaMinutes % 60}m`;
|
||||
}
|
||||
return `${deltaMinutes}m`;
|
||||
}
|
||||
|
||||
export function parseLogDetail(detailText) {
|
||||
if (!detailText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(detailText);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [{ key: 'detail', value: String(detailText) }];
|
||||
}
|
||||
return Object.entries(parsed).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
} catch {
|
||||
return [{ key: 'detail', value: String(detailText) }];
|
||||
}
|
||||
}
|
||||
|
||||
export function readSearchParam(searchParams, key) {
|
||||
const rawValue = searchParams?.[key];
|
||||
if (Array.isArray(rawValue)) {
|
||||
return String(rawValue[0] || '');
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
return rawValue;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
203
nextjs/src/lib/security.js
Normal file
203
nextjs/src/lib/security.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { cookieSecure } from './config.js';
|
||||
|
||||
const state = globalThis.__filesLehnertSecurityState || (globalThis.__filesLehnertSecurityState = {});
|
||||
|
||||
if (!state.jwtSecret) {
|
||||
state.jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
if (!state.loginAttempts) {
|
||||
state.loginAttempts = new Map();
|
||||
}
|
||||
|
||||
const authCookieName = 'auth';
|
||||
const csrfCookieName = 'csrf';
|
||||
const jwtMaxAgeSeconds = 2 * 60 * 60;
|
||||
const loginWindowMs = 15 * 60 * 1000;
|
||||
const loginMaxAttempts = 10;
|
||||
|
||||
function firstForwardedPart(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value.split(',')[0].trim();
|
||||
}
|
||||
|
||||
async function expectedOrigin() {
|
||||
const headerStore = await headers();
|
||||
const host = headerStore.get('x-forwarded-host') || headerStore.get('host') || '';
|
||||
const forwardedProto = firstForwardedPart(headerStore.get('x-forwarded-proto'));
|
||||
const protocol = forwardedProto || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||
|
||||
return host ? `${protocol}://${host}` : '';
|
||||
}
|
||||
|
||||
async function verifySameOrigin() {
|
||||
const headerStore = await headers();
|
||||
const source = headerStore.get('origin') || headerStore.get('referer');
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(source);
|
||||
} catch {
|
||||
throw new Error('origin-check-failed');
|
||||
}
|
||||
|
||||
const expected = await expectedOrigin();
|
||||
if (!expected || parsed.origin !== expected) {
|
||||
throw new Error('origin-check-failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCsrfToken() {
|
||||
const cookieStore = await cookies();
|
||||
const cookieToken = cookieStore.get(csrfCookieName)?.value;
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
|
||||
const headerStore = await headers();
|
||||
const headerToken = headerStore.get('x-csrf-token');
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function verifyCsrf(formData) {
|
||||
await verifySameOrigin();
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const expectedToken = cookieStore.get(csrfCookieName)?.value || '';
|
||||
|
||||
let providedToken = '';
|
||||
if (formData && typeof formData.get === 'function') {
|
||||
providedToken = String(formData.get('csrfToken') || '');
|
||||
}
|
||||
|
||||
if (!providedToken) {
|
||||
const headerStore = await headers();
|
||||
providedToken = String(headerStore.get('x-csrf-token') || '');
|
||||
}
|
||||
|
||||
if (!expectedToken || !providedToken || expectedToken !== providedToken) {
|
||||
throw new Error('csrf-token-mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequestMeta() {
|
||||
const headerStore = await headers();
|
||||
const forwardedFor = firstForwardedPart(headerStore.get('x-forwarded-for'));
|
||||
const realIp = headerStore.get('x-real-ip') || '';
|
||||
const ip = forwardedFor || realIp || 'unknown';
|
||||
const userAgent = headerStore.get('user-agent') || '';
|
||||
|
||||
return { ip, userAgent };
|
||||
}
|
||||
|
||||
export async function getAuthenticatedUser() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, state.jwtSecret);
|
||||
if (!payload || typeof payload !== 'object' || !payload.sub) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
username: String(payload.sub),
|
||||
admin: Boolean(payload.admin),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAuthCookie(payload) {
|
||||
const token = jwt.sign(payload, state.jwtSecret, { expiresIn: jwtMaxAgeSeconds });
|
||||
const cookieStore = await cookies();
|
||||
|
||||
cookieStore.set(authCookieName, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: jwtMaxAgeSeconds,
|
||||
secure: cookieSecure,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAuthCookie() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
secure: cookieSecure,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function requireAuthenticatedUser() {
|
||||
const user = await getAuthenticatedUser();
|
||||
if (!user) {
|
||||
await clearAuthCookie();
|
||||
redirect('/manage/login');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdminUser() {
|
||||
const user = await getAuthenticatedUser();
|
||||
if (!user || !user.admin) {
|
||||
await clearAuthCookie();
|
||||
redirect('/manage/admin');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function loginAttemptKey(type) {
|
||||
const meta = await getRequestMeta();
|
||||
return `${type}:${meta.ip || 'unknown'}`;
|
||||
}
|
||||
|
||||
export async function checkLoginRateLimit(type) {
|
||||
const key = await loginAttemptKey(type);
|
||||
const now = Date.now();
|
||||
|
||||
const entry = state.loginAttempts.get(key) || {
|
||||
count: 0,
|
||||
resetAt: now + loginWindowMs,
|
||||
};
|
||||
|
||||
if (now > entry.resetAt) {
|
||||
entry.count = 0;
|
||||
entry.resetAt = now + loginWindowMs;
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
state.loginAttempts.set(key, entry);
|
||||
|
||||
if (entry.count > loginMaxAttempts) {
|
||||
return Math.ceil((entry.resetAt - now) / 60_000);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function clearLoginRateLimit(type) {
|
||||
const key = await loginAttemptKey(type);
|
||||
state.loginAttempts.delete(key);
|
||||
}
|
||||
Reference in New Issue
Block a user