diff --git a/.env.example b/.env.example index a6e76a2..cd7d458 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ SERVICE_FQDN=files.example.com LETSENCRYPT_EMAIL=user@example.com DATA_DIR=/storagebox UPLOAD_TTL_SECONDS=604800 +UPLOAD_MAX_BYTES=0 MANAGEMENT_ADMIN_HASH= +COOKIE_SECURE=true diff --git a/.gitignore b/.gitignore index e16c576..fb18d95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .env -.logins traefik/ node_modules/ -expressjs/data/ +nextjs/data/ +nextjs/.next/ diff --git a/README.md b/README.md index 2e4cbe7..4677d2e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ # lehnert.cloud/files File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert.cloud). + +## Komponenten + +- `webserver` (Apache) stellt das öffentliche Dateiverzeichnis bereit +- `nextjs` enthält die Next.js-App für Verwaltung und Authentifizierung +- `traefik` übernimmt TLS und Routing + +## Management-UI + +- Benutzer-Dashboard: `/manage/login` +- Admin-Dashboard: `/manage/admin` +- Datei-Downloads: `/_share/` + +## Lokale Initialisierung + +```bash +./initialize.sh +``` + +Danach: + +1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` und `COOKIE_SECURE`) +2. Stack starten: `docker compose up --build` +3. Als Admin anmelden und Benutzer über die UI anlegen diff --git a/docker-compose.yml b/docker-compose.yml index 4c0a81b..697e905 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,41 +55,37 @@ services: restart: unless-stopped - expressjs: + nextjs: build: - context: ./expressjs + context: ./nextjs - container_name: expressjs + container_name: nextjs stop_grace_period: 5s environment: - - BASE_PATH=/manage - DATA_DIR=/data - DB_PATH=/app/data/uploads.sqlite - - LOGIN_FILE=/app/.logins - UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS} - MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH} - - TRUST_PROXY=true - PORT=3000 volumes: - "./data:/app/data" - - "./.logins:/app/.logins:ro" - "${DATA_DIR}:/data" labels: - "traefik.enable=true" - - "traefik.http.routers.express.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))" - - "traefik.http.routers.express.entrypoints=websecure" - - "traefik.http.routers.express.tls=true" - - "traefik.http.routers.express.tls.certresolver=letsencrypt" - - "traefik.http.routers.express.service=express-svc" - - "traefik.http.services.express-svc.loadbalancer.server.port=3000" - - "traefik.http.routers.express.priority=10" + - "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))" + - "traefik.http.routers.nextjs.entrypoints=websecure" + - "traefik.http.routers.nextjs.tls=true" + - "traefik.http.routers.nextjs.tls.certresolver=letsencrypt" + - "traefik.http.routers.nextjs.service=nextjs-svc" + - "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000" + - "traefik.http.routers.nextjs.priority=10" # Optional HTTP redirect - - "traefik.http.routers.express-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))" - - "traefik.http.routers.express-http.entrypoints=web" - - "traefik.http.routers.express-http.middlewares=express-https-redirect" - - "traefik.http.middlewares.express-https-redirect.redirectscheme.scheme=https" + - "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))" + - "traefik.http.routers.nextjs-http.entrypoints=web" + - "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect" + - "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https" restart: unless-stopped diff --git a/expressjs/package.json b/expressjs/package.json deleted file mode 100644 index 3472872..0000000 --- a/expressjs/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "files-lehnert-express", - "version": "1.0.0", - "private": true, - "main": "src/server.js", - "scripts": { - "start": "node src/server.js" - }, - "dependencies": { - "bcryptjs": "^2.4.3", - "cookie-parser": "^1.4.6", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1", - "sqlite3": "^5.1.7" - } -} diff --git a/expressjs/src/server.js b/expressjs/src/server.js deleted file mode 100644 index 08eda27..0000000 --- a/expressjs/src/server.js +++ /dev/null @@ -1,2108 +0,0 @@ -const crypto = require('crypto'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const bcrypt = require('bcryptjs'); -const cookieParser = require('cookie-parser'); -const dotenv = require('dotenv'); -const express = require('express'); -const jwt = require('jsonwebtoken'); -const multer = require('multer'); -const sqlite3 = require('sqlite3').verbose(); - -dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); - -const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/manage'; -const port = parseInt(process.env.PORT || '3000', 10); -const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); -const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite'); -const adminHash = process.env.MANAGEMENT_ADMIN_HASH || ''; -const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10); -const maxRetentionSeconds = 90 * 24 * 60 * 60; -const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); -const shareDir = path.join(dataDir, '_share'); - -const jwtSecret = crypto.randomBytes(32).toString('hex'); -const jwtMaxAgeMs = 2 * 60 * 60 * 1000; - -fs.mkdirSync(shareDir, { recursive: true }); -const tempDir = path.join(os.tmpdir(), 'uploads'); -fs.mkdirSync(tempDir, { recursive: true }); - -const upload = multer({ - dest: tempDir, - limits: maxUploadBytes > 0 ? { fileSize: maxUploadBytes } : undefined, -}); - -const app = express(); -const trustProxy = process.env.TRUST_PROXY === 'true'; -app.set('trust proxy', trustProxy); -app.use(cookieParser()); -app.use(express.urlencoded({ extended: true })); -app.use(express.json()); -app.use((req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - next(); -}); - -const db = new sqlite3.Database(dbPath); - -db.serialize(() => { - db.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 - )`); - db.run('CREATE INDEX IF NOT EXISTS uploads_owner_idx ON uploads(owner)'); - db.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)'); - db.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 - )`); - db.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', (err) => { /* ignore if column exists */ }); - db.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', (err) => { /* ignore if column exists */ }); - db.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', (err) => { /* ignore if column exists */ }); - 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 TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - password_hash TEXT NOT NULL, - created_at INTEGER NOT NULL - )`); -}); - -function run(sql, params = []) { - return new Promise((resolve, reject) => { - db.run(sql, params, function (err) { - if (err) { - reject(err); - return; - } - resolve(this); - }); - }); -} - -function get(sql, params = []) { - return new Promise((resolve, reject) => { - db.get(sql, params, (err, row) => { - if (err) { - reject(err); - return; - } - resolve(row); - }); - }); -} - -function all(sql, params = []) { - return new Promise((resolve, reject) => { - db.all(sql, params, (err, rows) => { - if (err) { - reject(err); - return; - } - resolve(rows); - }); - }); -} - -function logEvent(event, owner, detail, req = null) { - const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {}); - const ip = req ? (req.ip || req.socket.remoteAddress) : null; - const userAgent = req ? req.get('User-Agent') : 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); -} - -const csrfCookieName = 'csrf'; -const csrfCookieOptions = { - httpOnly: true, - sameSite: 'strict', - secure: process.env.COOKIE_SECURE === 'true', -}; - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function csrfField(token) { - return ``; -} - -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 true; - } - 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', '

Origin-Prüfung fehlgeschlagen.

')); - return; - } - - const token = req.cookies[csrfCookieName]; - const provided = req.body?.csrfToken || req.query?.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', '

CSRF-Prüfung fehlgeschlagen.

')); - return; - } - - next(); -} - -const loginAttempts = new Map(); -const LOGIN_WINDOW_MS = 15 * 60 * 1000; -const LOGIN_MAX_ATTEMPTS = 10; - -function loginRateLimit(type) { - return (req, res, next) => { - const ip = req.ip || 'unknown'; - const key = `${type}:${ip}`; - const now = Date.now(); - const entry = loginAttempts.get(key) || { count: 0, resetAt: now + LOGIN_WINDOW_MS }; - if (now > entry.resetAt) { - entry.count = 0; - entry.resetAt = now + LOGIN_WINDOW_MS; - } - entry.count += 1; - loginAttempts.set(key, entry); - if (entry.count > LOGIN_MAX_ATTEMPTS) { - const waitMinutes = Math.ceil((entry.resetAt - now) / 60000); - const body = `

Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.

`; - res.status(429).send(renderPage('Zu viele Versuche', body)); - return; - } - next(); - }; -} - -function clearLoginAttempts(type, req) { - const ip = req.ip || 'unknown'; - loginAttempts.delete(`${type}:${ip}`); -} - -async function getUserHash(username) { - const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]); - return row ? row.password_hash : null; -} - -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 sanitizeBaseName(originalName) { - const ext = path.extname(originalName || ''); - const base = path.basename(originalName || 'datei', ext); - // Allow more characters but keep it safe for URLs and FS - const cleaned = base - .replace(/[^\w\-. ]/g, '') // Allow words, dashes, dots, spaces - .trim() - .replace(/\s+/g, '-'); // Replace spaces with dashes for better URLs - return cleaned || 'datei'; -} - -function sanitizeExtension(originalName) { - const ext = path.extname(originalName || '').toLowerCase(); - if (!ext) { - return ''; - } - if (!/^\.[a-z0-9]{1,10}$/.test(ext)) { - return ''; - } - return ext; -} - -function formatBytes(bytes) { - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ['KB', 'MB', 'GB', 'TB']; - let value = bytes / 1024; - let idx = 0; - while (value >= 1024 && idx < units.length - 1) { - value /= 1024; - idx += 1; - } - return `${value.toFixed(value < 10 ? 1 : 0)} ${units[idx]}`; -} - -function formatTimestamp(ts) { - const date = new Date(ts); - return date.toLocaleString(); -} - -function formatCountdown(ts) { - const delta = Math.max(0, ts - Date.now()); - const minutes = Math.floor(delta / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - if (days > 0) { - return `${days}d ${hours % 24}h`; - } - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - return `${minutes}m`; -} - -function formatDetail(detailJson) { - try { - const obj = JSON.parse(detailJson); - if (!obj || typeof obj !== 'object') return escapeHtml(detailJson); - - // Check if empty object - if (Object.keys(obj).length === 0) return ''; - - let html = '
'; - for (const [key, val] of Object.entries(obj)) { - html += `
${escapeHtml(key)}:
${escapeHtml(val)}
`; - } - html += '
'; - return html; - } catch (e) { - return escapeHtml(detailJson); - } -} - -function renderFileManagerPage(title, body) { - return ` - - - - - ${title} - - - -
- ${body} -
- -`; -} - -function renderPage(title, body, mainClass = '') { - return ` - - - - - ${title} - - - -
- ${body} -
- -`; -} - -function baseUrl(pathname) { - return `${basePath}${pathname}`; -} - -function getUserFromRequest(req) { - const token = req.cookies?.auth; - if (!token) { - return null; - } - try { - const payload = jwt.verify(token, jwtSecret); - if (!payload?.sub) { - return null; - } - return { username: payload.sub, admin: Boolean(payload.admin) }; - } catch (err) { - return null; - } -} - -function requireAuthPage(req, res, next) { - const user = getUserFromRequest(req); - if (!user) { - res.clearCookie('auth'); - res.redirect(baseUrl('/login')); - return; - } - req.user = user; - next(); -} - -function requireAdminPage(req, res, next) { - const user = getUserFromRequest(req); - if (!user || !user.admin) { - res.clearCookie('auth'); - res.redirect(baseUrl('/admin')); - return; - } - req.user = user; - 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) { - res.clearCookie('auth'); - res.status(401).json({ error: 'Unauthorized' }); - return; - } - req.user = user; - next(); -} - -async function cleanupExpired() { - 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 (err) { - // File might already be gone. - } - await run('DELETE FROM uploads WHERE id = ?', [entry.id]); - removed += 1; - } - if (removed > 0) { - await logEvent('cleanup', null, { removed }); - } -} - -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(() => { - cleanupExpired().catch(() => undefined); -}, 60 * 1000); -cleanupExpired().catch(() => undefined); - -app.get(`${basePath}/`, (req, res) => { - const user = getUserFromRequest(req); - if (user) { - res.redirect(baseUrl('/dashboard')); - return; - } - res.redirect(baseUrl('/login')); -}); - -app.get(`${basePath}/login`, (req, res) => { - const user = getUserFromRequest(req); - if (user) { - res.redirect(baseUrl('/dashboard')); - return; - } - const body = ` -
-
-

Dateiverwaltung

-
Bitte anmelden, um Uploads zu verwalten.
-
-
-
-
- ${csrfField(res.locals.csrfToken)} - - - -
-
- `; - res.send(renderPage('Anmeldung', body)); -}); - -app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => { - const username = String(req.body.username || '').trim(); - const password = String(req.body.password || ''); - const hash = await getUserHash(username); - - if (!hash || !bcrypt.compareSync(password, hash)) { - const body = ` -
-
-

Dateiverwaltung

-
Bitte anmelden, um Uploads zu verwalten.
-
-
-
-
Anmeldung fehlgeschlagen
-
- ${csrfField(res.locals.csrfToken)} - - - -
-
- `; - res.status(401).send(renderPage('Anmeldung', body)); - return; - } - - const token = jwt.sign({ sub: username }, jwtSecret, { expiresIn: '2h' }); - res.cookie('auth', token, { - httpOnly: true, - sameSite: 'lax', - maxAge: jwtMaxAgeMs, - secure: process.env.COOKIE_SECURE === 'true', - }); - clearLoginAttempts('user', req); - await logEvent('login', username, { ok: true }, req); - res.redirect(baseUrl('/dashboard')); -}); - -app.post(`${basePath}/logout`, (req, res) => { - res.clearCookie('auth'); - res.redirect(baseUrl('/login')); -}); - -app.get(`${basePath}/admin`, async (req, res) => { - if (!adminHash) { - res.status(404).send(renderPage('Admin', '

Admin-Zugang ist nicht konfiguriert.

')); - return; - } - const user = getUserFromRequest(req); - if (user?.admin) { - res.redirect(baseUrl('/admin/dashboard')); - return; - } - const body = ` -
-
-

Adminbereich

-
Admin-Passwort eingeben.
-
-
-
-
- ${csrfField(res.locals.csrfToken)} - - -
-
- `; - res.send(renderPage('Admin', body)); -}); - -app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) => { - if (!adminHash) { - res.status(404).send(renderPage('Admin', '

Admin-Zugang ist nicht konfiguriert.

')); - return; - } - const password = String(req.body.password || ''); - if (!bcrypt.compareSync(password, adminHash)) { - const body = ` -
-
-

Adminbereich

-
Admin-Passwort eingeben.
-
-
-
-
Anmeldung fehlgeschlagen
-
- ${csrfField(res.locals.csrfToken)} - - -
-
- `; - res.status(401).send(renderPage('Admin', body)); - return; - } - const token = jwt.sign({ sub: 'admin', admin: true }, jwtSecret, { expiresIn: '2h' }); - res.cookie('auth', token, { - httpOnly: true, - sameSite: 'lax', - maxAge: jwtMaxAgeMs, - secure: process.env.COOKIE_SECURE === 'true', - }); - clearLoginAttempts('admin', req); - await logEvent('admin_login', 'admin', { ok: true }, req); - res.redirect(baseUrl('/admin/dashboard')); -}); - -app.post(`${basePath}/admin/logout`, (req, res) => { - res.clearCookie('auth'); - res.redirect(baseUrl('/admin')); -}); - -app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => { - const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; - - const [ - activeCount, - activeBytes, - distinctOwners, - totalUploads, - totalDeletes, - lastCleanup, - recentLogs, - allUploads, - chartDataRaw - ] = await Promise.all([ - get('SELECT COUNT(*) as count FROM uploads'), - get('SELECT COALESCE(SUM(size_bytes), 0) as total FROM uploads'), - get('SELECT COUNT(DISTINCT owner) as count FROM uploads'), - get('SELECT COUNT(*) as count FROM admin_logs WHERE event = ?', ['upload']), - get('SELECT COUNT(*) as count FROM admin_logs WHERE event IN (?, ?)', ['delete', 'cleanup']), - get('SELECT MAX(created_at) as ts FROM admin_logs WHERE event = ?', ['cleanup']), - all('SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 500'), - all('SELECT id, owner, original_name, stored_name, size_bytes, expires_at FROM uploads ORDER BY uploaded_at DESC'), - all('SELECT created_at, event FROM admin_logs WHERE created_at > ?', [thirtyDaysAgo]) - ]); - - // Process chart data - const dailyStats = {}; - for (let i = 0; i < 30; i++) { - const d = new Date(); - d.setDate(d.getDate() - i); - const dateStr = d.toISOString().split('T')[0]; - dailyStats[dateStr] = { upload: 0, download: 0, delete: 0 }; - } - - for (const row of chartDataRaw) { - const dateStr = new Date(row.created_at).toISOString().split('T')[0]; - if (dailyStats[dateStr]) { - if (row.event === 'upload' || row.event === 'admin_upload') dailyStats[dateStr].upload++; - else if (row.event === 'download') dailyStats[dateStr].download++; - else if (row.event === 'delete' || row.event === 'admin_delete') dailyStats[dateStr].delete++; - } - } - - const chartLabels = Object.keys(dailyStats).sort(); - const chartUploads = chartLabels.map(d => dailyStats[d].upload); - const chartDownloads = chartLabels.map(d => dailyStats[d].download); - const chartDeletes = chartLabels.map(d => dailyStats[d].delete); - - const stats = ` -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Aktive Uploads${activeCount.count}
Aktive Größe${formatBytes(activeBytes.total)}
Aktive Nutzer${distinctOwners.count}
Uploads gesamt${totalUploads.count}
Downloads gesamt${await get('SELECT SUM(downloads) as count FROM uploads').then(r => r.count || 0)}
Löschungen gesamt${totalDeletes.count}
Letztes Cleanup${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}
-
- `; - - const logRows = recentLogs.map((entry) => ` - - ${formatTimestamp(entry.created_at)} - ${escapeHtml(entry.event)} - -
${escapeHtml(entry.owner || '—')}
-
${escapeHtml(entry.ip || '')}
- - - ${formatDetail(entry.detail)} - ${entry.user_agent ? `
${escapeHtml(entry.user_agent)}
` : ''} - - - `).join(''); - - const adminUploadsRows = allUploads.map((item) => { - const fileUrl = `/_share/${item.stored_name}`; - const fileHref = encodeURI(fileUrl); - return ` - - ${escapeHtml(item.owner)} - -
${escapeHtml(item.original_name)}
-
${escapeHtml(item.stored_name)}
- - ${formatBytes(item.size_bytes)} - -
${formatTimestamp(item.expires_at)}
-
Noch ${formatCountdown(item.expires_at)}
- - - -
- ${csrfField(res.locals.csrfToken)} - -
-
- ${csrfField(res.locals.csrfToken)} - - -
- - - `; - }).join(''); - - const body = ` - -
-
-

Adminübersicht

-
Systemstatistiken und Logs
-
-
- Dateimanager - Benutzer verwalten -
- ${csrfField(res.locals.csrfToken)} - -
-
-
- -
-
-

Aktivität (30 Tage)

- -
-
-

Statistiken

- ${stats} -
-
- -
-

Letzte Ereignisse

- - - - - - - - - - - ${logRows || ''} - -
ZeitEventNutzer / IPDetails / User-Agent
Keine Logs vorhanden.
-
- -
-

Aktuelle Uploads

- ${allUploads.length ? ` - - - - - - - - - - - - ${adminUploadsRows} - -
NutzerDateiGrößeLäuft abAktionen
- ` : '
Noch keine Uploads.
'} -
- - - `; - 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 ` - - ${username} - ${formatTimestamp(user.created_at)} - -
- ${csrfField(res.locals.csrfToken)} - - -
-
- ${csrfField(res.locals.csrfToken)} - -
- - - `; - }).join(''); - - const body = ` -
-
-

Benutzer verwalten

-
Zugänge im System verwalten.
-
-
- Zur Adminübersicht -
- ${csrfField(res.locals.csrfToken)} - -
-
-
- -
-

Neuen Benutzer anlegen

-
- ${csrfField(res.locals.csrfToken)} - - - -
-
- -
-

Benutzerliste

- ${users.length ? ` - - - - - - - - - - ${rows} - -
BenutzernameErstelltAktionen
- ` : '
Keine Benutzer vorhanden.
'} -
- `; - - 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', '

Ungültige Eingabe.

', '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', '

Benutzername existiert bereits.

', 'wide')); - return; - } - await logEvent('admin_user_create', 'admin', { username }, req); - 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', '

Ungültige Eingabe.

', '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 }, req); - 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', '

Ungültige Eingabe.

', 'wide')); - return; - } - await run('DELETE FROM users WHERE username = ?', [username]); - await logEvent('admin_user_delete', 'admin', { username }, req); - res.redirect(baseUrl('/admin/users')); -}); - -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', '

Ungültiger Pfad.

')); - return; - } - - let entries; - try { - entries = await fs.promises.readdir(resolved, { withFileTypes: true }); - } catch (err) { - res.status(500).send(renderFileManagerPage('Admin-Dateien', '

Ordner kann nicht gelesen werden.

')); - return; - } - - const parentPath = relativePath ? relativePath.split('/').slice(0, -1).join('/') : ''; - - const filtered = entries.filter((entry) => entry.name !== '_share'); - const details = await Promise.all( - filtered.map(async (entry) => { - const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; - let stat = null; - try { - stat = await fs.promises.stat(path.join(resolved, entry.name)); - } catch (err) { - stat = null; - } - return { - entry, - childPath, - isDir: entry.isDirectory(), - size: stat && stat.isFile() ? stat.size : null, - modifiedAt: stat ? stat.mtimeMs : null, - }; - }) - ); - - const dirs = details.filter((item) => item.isDir); - const files = details.filter((item) => !item.isDir); - - const rowForEntry = (item) => { - const { entry, childPath, isDir, size, modifiedAt } = item; - const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`); - const escapedName = escapeHtml(entry.name); - const escapedPath = escapeHtml(childPath); - return ` - - - - ${isDir ? 'DIR' : 'FILE'} - ${isDir ? `${escapedName}` : escapedName} - - - ${isDir ? 'Ordner' : 'Datei'} - ${size ? formatBytes(size) : '—'} - ${modifiedAt ? formatTimestamp(modifiedAt) : '—'} - - - - - - - - `; - }; - - const tableRows = [ - ...dirs.map((entry) => rowForEntry(entry)), - ...files.map((entry) => rowForEntry(entry)), - ].join(''); - - const body = ` -
-
-

Admin-Dateimanager

-
Verwalten aller Dateien (außer _share).
-
-
- Zur Adminübersicht -
- ${csrfField(res.locals.csrfToken)} - -
-
-
- -
-
- Pfad /${escapeHtml(relativePath || '')} - ${relativePath ? `← Zurück` : ''} -
- Position: - ${relativePath ? relativePath.split('/').map((segment, idx, parts) => { - const crumbPath = parts.slice(0, idx + 1).join('/'); - return `${escapeHtml(segment)}`; - }).join('/') : 'Root'} -
-
-
-
-

Ordner erstellen

-
- ${csrfField(res.locals.csrfToken)} - - - -
-
-
-

Datei hochladen

-
- ${csrfField(res.locals.csrfToken)} - - - -
-
-
-
- -
-

Inhalt

- ${tableRows ? ` - - - - - - - - - - - - ${tableRows} - -
NameTypGrößeGeändertAktionen
- ` : '
Keine Eintraege in diesem Ordner.
'} -
- - -
- ${csrfField(res.locals.csrfToken)} - -

Umbenennen

-
- -
- - -
-
-
- - -
- ${csrfField(res.locals.csrfToken)} - -

Löschen

-
-
- - -
-
-
- - -
- ${csrfField(res.locals.csrfToken)} - -

Verschieben

-
- -
- - -
-
-
- - -
- ${csrfField(res.locals.csrfToken)} - -

Kopieren

-
- -
- - -
-
-
- - - `; - - 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', '

Ungültiger Ordnername.

')); - return; - } - const base = resolveAdminPath(relativePath); - if (!base) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Pfad.

')); - return; - } - const target = path.join(base, name); - await fs.promises.mkdir(target, { recursive: true }); - await logEvent('admin_mkdir', 'admin', { path: path.join(relativePath, name) }, req); - 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', '

Ungültiger Pfad.

')); - return; - } - if (!req.file) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Keine Datei hochgeladen.

')); - return; - } - const filename = path.basename(req.file.originalname); - if (filename === '_share') { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Dateiname.

')); - 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) }, req); - 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', '

Root kann nicht umbenannt werden.

')); - return; - } - if (!newName || newName.includes('/') || newName.includes('\\') || newName === '_share') { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger neuer Name.

')); - return; - } - const resolved = resolveAdminPath(relativePath); - if (!resolved) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Pfad.

')); - 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) }, req); - 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', '

Root kann nicht gelöscht werden.

')); - return; - } - const resolved = resolveAdminPath(relativePath); - if (!resolved) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Pfad.

')); - return; - } - await fs.promises.rm(resolved, { recursive: true, force: true }); - await logEvent('admin_delete', 'admin', { path: relativePath }, req); - const parent = path.dirname(relativePath); - const nextPath = parent === '.' ? '' : parent; - res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`)); -}); - -app.post(`${basePath}/admin/files/move`, requireAdminPage, async (req, res) => { - const relativePath = String(req.body.path || '').replace(/^\/+/, ''); - const targetPath = String(req.body.targetPath || '').replace(/^\/+/, ''); - if (!relativePath || !targetPath) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültige Eingabe.

')); - return; - } - const source = resolveAdminPath(relativePath); - const targetBase = resolveAdminPath(targetPath); - if (!source || !targetBase) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Pfad.

')); - return; - } - - let target = targetBase; - try { - const stat = await fs.promises.stat(targetBase); - if (stat.isDirectory()) { - target = path.join(targetBase, path.basename(source)); - } - } catch (err) { - // targetBase does not exist; treat as file/dir path. - } - - try { - await fs.promises.rename(source, target); - } catch (err) { - if (err.code === 'EXDEV') { - await fs.promises.cp(source, target, { recursive: true, force: false }); - await fs.promises.rm(source, { recursive: true, force: true }); - } else { - throw err; - } - } - await logEvent('admin_move', 'admin', { from: relativePath, to: targetPath }, req); - res.redirect(baseUrl('/admin/files')); -}); - -app.post(`${basePath}/admin/files/copy`, requireAdminPage, async (req, res) => { - const relativePath = String(req.body.path || '').replace(/^\/+/, ''); - const targetPath = String(req.body.targetPath || '').replace(/^\/+/, ''); - if (!relativePath || !targetPath) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültige Eingabe.

')); - return; - } - const source = resolveAdminPath(relativePath); - const targetBase = resolveAdminPath(targetPath); - if (!source || !targetBase) { - res.status(400).send(renderFileManagerPage('Admin-Dateien', '

Ungültiger Pfad.

')); - return; - } - - let target = targetBase; - try { - const stat = await fs.promises.stat(targetBase); - if (stat.isDirectory()) { - target = path.join(targetBase, path.basename(source)); - } - } catch (err) { - // targetBase does not exist; treat as file/dir path. - } - - await fs.promises.cp(source, target, { recursive: true, force: false }); - await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath }, req); - res.redirect(baseUrl('/admin/files')); -}); - -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) { - res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); - return; - } - try { - await fs.promises.unlink(uploadEntry.stored_path); - } catch (err) { - // Ignore missing files. - } - await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); - await logEvent('delete', 'admin', { id: uploadEntry.id }, req); - res.redirect(baseUrl('/admin/dashboard')); -}); - -app.post(`${basePath}/admin/files/:id/extend`, requireAdminPage, async (req, res) => { - const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ?', [req.params.id]); - if (!uploadEntry) { - res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); - return; - } - - const override = parseFloat(req.body.extendHours || ''); - const extensionSeconds = Number.isFinite(override) && override > 0 - ? Math.round(override * 3600) - : uploadTtlSeconds; - - const now = Date.now(); - const base = Math.max(uploadEntry.expires_at, now); - const maxExpiry = now + maxRetentionSeconds * 1000; - const nextExpiry = Math.min(base + 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 }, req); - res.redirect(baseUrl('/admin/dashboard')); -}); - -app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => { - const uploads = await all( - 'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC', - [req.user.username] - ); - - const rows = uploads.map((item) => { - const fileUrl = `/_share/${item.stored_name}`; - const fileHref = encodeURI(fileUrl); - return ` - - -
${escapeHtml(item.original_name)}
-
${escapeHtml(item.stored_name)}
- - ${formatBytes(item.size_bytes)} - -
${formatTimestamp(item.expires_at)}
-
Noch ${formatCountdown(item.expires_at)}
- - - -
- ${csrfField(res.locals.csrfToken)} - -
-
- ${csrfField(res.locals.csrfToken)} - - -
- - - `; - }).join(''); - - const body = ` -
-
-

Dateiverwaltung

-
Angemeldet als ${escapeHtml(req.user.username)}
-
-
- ${csrfField(res.locals.csrfToken)} - -
-
- -
-

Datei hochladen

-
- ${csrfField(res.locals.csrfToken)} - - - - -
-
-
- -
-

Aktuelle Uploads

- ${uploads.length ? ` - - - - - - - - - - - ${rows} - -
DateiGrößeLäuft abAktionen
- ` : '
Noch keine Uploads.
'} -
- - - `; - - res.send(renderPage('Übersicht', body)); -}); - -app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async (req, res) => { - if (!req.file) { - res.status(400).json({ error: 'No file uploaded' }); - return; - } - - const now = Date.now(); - // Don't sanitize rigorously anymore, we rely on content-disposition header for safety - // but we still want a safe filename for storage - const ext = path.extname(req.file.originalname); - const token = createRandomId(); - // We keep the original filename in the DB but store it with a safe ID on disk - const storedName = `${token}${ext}`; - const storedPath = path.join(shareDir, storedName); - - const retentionOverride = parseFloat(req.body.retentionHours || ''); - const retentionSeconds = Number.isFinite(retentionOverride) && retentionOverride > 0 - ? Math.round(retentionOverride * 3600) - : uploadTtlSeconds; - const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds); - - try { - await fs.promises.rename(req.file.path, storedPath); - } catch (err) { - if (err.code === 'EXDEV') { - await fs.promises.copyFile(req.file.path, storedPath); - await fs.promises.unlink(req.file.path); - } else { - throw err; - } - } - - await run( - `INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - req.user.username, - req.file.originalname, // Store exact original name - storedName, - storedPath, - req.file.size, - now, - now + cappedRetention * 1000, - ] - ); - await logEvent('upload', req.user.username, { name: storedName, size: req.file.size }, req); - - res.json({ ok: true, name: storedName }); -}); - -app.post(`${basePath}/files/:id/delete`, requireAuthPage, async (req, res) => { - const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ? AND owner = ?', [ - req.params.id, - req.user.username, - ]); - if (!uploadEntry) { - res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); - return; - } - try { - await fs.promises.unlink(uploadEntry.stored_path); - } catch (err) { - // Ignore missing files. - } - await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); - await logEvent('delete', req.user.username, { id: uploadEntry.id }, req); - res.redirect(baseUrl('/dashboard')); -}); - -app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => { - const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ? AND owner = ?', [ - req.params.id, - req.user.username, - ]); - if (!uploadEntry) { - res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); - return; - } - - const override = parseFloat(req.body.extendHours || ''); - const extensionSeconds = Number.isFinite(override) && override > 0 - ? Math.round(override * 3600) - : uploadTtlSeconds; - - const now = Date.now(); - const base = Math.max(uploadEntry.expires_at, now); - const maxExpiry = now + maxRetentionSeconds * 1000; - const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry); - await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]); - await logEvent('extend', req.user.username, { id: uploadEntry.id, expires_at: nextExpiry }, req); - res.redirect(baseUrl('/dashboard')); -}); - -app.get('/_share/:filename', async (req, res) => { - const filename = req.params.filename; - // Security check: ensure no path traversal - if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) { - res.status(400).send('Invalid filename'); - return; - } - - const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [filename]); - - if (!row) { - // If not found in DB, check if it exists on disk (legacy or manual files) - const filePath = path.join(shareDir, filename); - if (fs.existsSync(filePath)) { - // Log download for legacy/manual files if needed, or just skip - res.download(filePath, filename); // Fallback: download with stored name - return; - } - res.status(404).send('File not found'); - return; - } - - // Log download - run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined); - logEvent('download', null, { name: filename, original: row.original_name }, req).catch(() => undefined); - - res.download(row.stored_path, row.original_name); -}); - -app.use((req, res) => { - res.status(404).send(renderPage('Nicht gefunden', '

Seite nicht gefunden.

')); -}); - -const server = app.listen(port, () => { - console.log(`Express server listening on ${port} with base path ${basePath}`); -}); - -function shutdown(signal) { - clearInterval(cleanupTimer); - server.close(() => { - db.close(() => { - console.log(`Shutdown complete (${signal}).`); - process.exit(0); - }); - }); - setTimeout(() => { - console.error('Forced shutdown after timeout.'); - process.exit(1); - }, 5000).unref(); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/initialize.sh b/initialize.sh index 77dd4aa..6eae967 100755 --- a/initialize.sh +++ b/initialize.sh @@ -12,13 +12,6 @@ mkdir -p ./data echo "Ensured ./traefik and ./data exist." -if [ ! -f .logins ]; then - cp .logins.example .logins - echo "Created .logins from .logins.example" -else - echo "Found existing .logins" -fi - if [ ! -f .env ]; then cp .env.example .env echo "Created .env from .env.example" @@ -28,6 +21,6 @@ fi echo "Initialization complete." echo "Next steps:" -echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS" -echo "2) Edit .logins to add users (bcrypt)" -echo "3) docker compose up --build" +echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS, optional UPLOAD_MAX_BYTES" +echo "2) Set MANAGEMENT_ADMIN_HASH in .env for admin login" +echo "3) Start with docker compose up --build" diff --git a/nextjs/.dockerignore b/nextjs/.dockerignore new file mode 100644 index 0000000..33d091d --- /dev/null +++ b/nextjs/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +npm-debug.log* +data diff --git a/expressjs/Dockerfile b/nextjs/Dockerfile similarity index 73% rename from expressjs/Dockerfile rename to nextjs/Dockerfile index 3599684..2326ef1 100644 --- a/expressjs/Dockerfile +++ b/nextjs/Dockerfile @@ -5,10 +5,14 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json* ./ -RUN npm install --omit=dev +RUN npm ci -COPY src ./src +COPY . ./ + +RUN npm run build ENV NODE_ENV=production -CMD ["node", "src/server.js"] +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/nextjs/app/%5Fshare/[filename]/route.js b/nextjs/app/%5Fshare/[filename]/route.js new file mode 100644 index 0000000..c4dbdf5 --- /dev/null +++ b/nextjs/app/%5Fshare/[filename]/route.js @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; + +import { NextResponse } from 'next/server'; + +import { shareDir } from '@/src/lib/config.js'; +import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { getRequestMeta } from '@/src/lib/security.js'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function safeFilename(value) { + const fileName = String(value || ''); + if (!fileName) { + return ''; + } + if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..')) { + return ''; + } + return fileName; +} + +function contentDisposition(filename) { + const fallback = String(filename || 'download') + .replace(/[\r\n]/g, ' ') + .replace(/[\\"]/g, '_') + .replace(/[^ -~]/g, '_'); + const encoded = encodeURIComponent(filename || 'download'); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} + +export async function GET(request, { params }) { + await runCleanupIfNeeded(); + + const resolvedParams = await params; + const fileName = safeFilename(resolvedParams.filename); + if (!fileName) { + return new NextResponse('Ungültiger Dateiname', { status: 400 }); + } + + const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [fileName]); + + let filePath; + let downloadName; + + if (row) { + filePath = row.stored_path; + downloadName = row.original_name || fileName; + + const requestMeta = await getRequestMeta(); + run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined); + logEvent('download', null, { name: fileName, original: downloadName }, requestMeta).catch(() => undefined); + } else { + filePath = path.join(shareDir, fileName); + downloadName = fileName; + } + + let fileStat; + try { + fileStat = await fs.promises.stat(filePath); + } catch { + return new NextResponse('Datei nicht gefunden', { status: 404 }); + } + + const fileStream = fs.createReadStream(filePath); + const webStream = Readable.toWeb(fileStream); + + return new NextResponse(webStream, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(fileStat.size), + 'Content-Disposition': contentDisposition(downloadName), + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css new file mode 100644 index 0000000..d6887d6 --- /dev/null +++ b/nextjs/app/globals.css @@ -0,0 +1,353 @@ +:root { + --font-body: 'Manrope', sans-serif; + --font-heading: 'Space Grotesk', sans-serif; + --bg-main: #eef4f7; + --bg-accent: #d9ece4; + --surface: #ffffff; + --surface-soft: #f7fafc; + --text-main: #10243a; + --text-muted: #566b81; + --line: #d6e0ea; + --primary: #0f766e; + --primary-hover: #0e635c; + --primary-soft: #d8f3ea; + --danger: #b42318; + --danger-soft: #fee4e2; + --radius: 16px; + --radius-sm: 10px; + --shadow: 0 16px 36px -24px rgb(16 36 58 / 0.45); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-body); + color: var(--text-main); + background: + radial-gradient(circle at 15% 20%, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.8) 35%, transparent 65%), + radial-gradient(circle at 85% 0%, rgb(217 236 228 / 0.75) 0%, transparent 45%), + linear-gradient(140deg, var(--bg-main), #f6f9fc 60%, #e8f2f7); + min-height: 100vh; +} + +a { + color: inherit; +} + +h1, +h2, +h3 { + margin: 0; + font-family: var(--font-heading); + letter-spacing: -0.02em; +} + +p { + margin: 0; +} + +.page-shell { + width: min(1200px, 100% - 2.5rem); + margin: 0 auto; + padding: 2.2rem 0 3.5rem; + display: grid; + gap: 1.5rem; + animation: page-enter 240ms ease-out both; +} + +.page-shell.narrow { + width: min(560px, 100% - 2rem); +} + +.page-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.9rem; +} + +.header-main { + display: grid; + gap: 0.3rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.panel { + background: linear-gradient(180deg, var(--surface), var(--surface-soft)); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.1rem; + display: grid; + gap: 0.95rem; +} + +.panel.centered { + text-align: center; + justify-items: center; +} + +.muted { + color: var(--text-muted); +} + +.status { + border-radius: var(--radius-sm); + padding: 0.65rem 0.8rem; + border: 1px solid transparent; + font-size: 0.93rem; +} + +.status.success { + background: #ddf5ea; + border-color: #b7e8d3; + color: #10513f; +} + +.status.error { + background: var(--danger-soft); + border-color: #f8b4af; + color: var(--danger); +} + +.form-grid { + display: grid; + gap: 0.8rem; +} + +.field { + display: grid; + gap: 0.35rem; + font-size: 0.92rem; + font-weight: 600; +} + +.input { + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: #fff; + color: var(--text-main); + font: inherit; + padding: 0.52rem 0.62rem; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.input:focus-visible { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgb(15 118 110 / 0.16); +} + +.input.small { + padding: 0.38rem 0.5rem; + font-size: 0.85rem; +} + +.btn { + appearance: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: var(--primary); + color: #fff; + text-decoration: none; + font: inherit; + font-weight: 600; + cursor: pointer; + padding: 0.48rem 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 140ms ease, transform 120ms ease, border-color 140ms ease; +} + +.btn:hover { + background: var(--primary-hover); +} + +.btn:active { + transform: translateY(1px); +} + +.btn.secondary { + background: #fff; + border-color: var(--line); + color: var(--text-main); +} + +.btn.secondary:hover { + background: #f4f8fb; + border-color: #c3d4e2; +} + +.btn.danger { + background: var(--danger-soft); + color: var(--danger); + border-color: #f7b0a8; +} + +.btn.danger:hover { + background: #fccfc9; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + border-radius: 999px; + background: #fff; + border: 1px solid var(--line); + color: var(--text-main); + text-decoration: none; + padding: 0.3rem 0.58rem; +} + +.chip.primary { + background: var(--primary-soft); + border-color: #9ddac6; + color: #0c4f4a; +} + +.metric-grid { + display: grid; + gap: 0.8rem; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.metric { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: #fff; + padding: 0.7rem; + display: grid; + gap: 0.2rem; +} + +.metric strong { + font-family: var(--font-heading); + font-size: 1.25rem; + letter-spacing: -0.02em; +} + +.table-wrap { + width: 100%; + overflow-x: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: #fff; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 680px; +} + +th, +td { + text-align: left; + vertical-align: top; + padding: 0.62rem 0.66rem; + border-top: 1px solid var(--line); + font-size: 0.9rem; +} + +thead th { + border-top: none; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.74rem; + color: var(--text-muted); + background: #f8fbfd; +} + +tbody tr:hover { + background: #f7fbfc; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} + +.stack-actions { + display: grid; + gap: 0.45rem; +} + +.inline-form { + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; +} + +.inline-form.stacked { + display: grid; +} + +.breadcrumbs { + display: flex; + gap: 0.35rem; + align-items: center; + flex-wrap: wrap; + font-size: 0.88rem; +} + +.breadcrumbs a { + color: #0f5f84; + text-decoration: none; +} + +.breadcrumbs a:hover { + text-decoration: underline; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 0.8rem; +} + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 900px) { + .page-shell { + width: min(100% - 1.4rem, 1200px); + padding-top: 1.3rem; + } + + .panel { + padding: 0.85rem; + } + + table { + min-width: 620px; + } +} diff --git a/nextjs/app/layout.js b/nextjs/app/layout.js new file mode 100644 index 0000000..15fcd76 --- /dev/null +++ b/nextjs/app/layout.js @@ -0,0 +1,28 @@ +import { Manrope, Space_Grotesk } from 'next/font/google'; + +import './globals.css'; + +const bodyFont = Manrope({ + subsets: ['latin'], + variable: '--font-body', + weight: ['400', '500', '600', '700'], +}); + +const headingFont = Space_Grotesk({ + subsets: ['latin'], + variable: '--font-heading', + weight: ['500', '700'], +}); + +export const metadata = { + title: 'Dateiverwaltung', + description: 'Dateiuploads und Admin-Verwaltung mit Next.js', +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/nextjs/app/manage/_components/copy-link-button.js b/nextjs/app/manage/_components/copy-link-button.js new file mode 100644 index 0000000..602484f --- /dev/null +++ b/nextjs/app/manage/_components/copy-link-button.js @@ -0,0 +1,47 @@ +'use client'; + +import { useState } from 'react'; + +function wait(milliseconds) { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +export function CopyLinkButton({ path, label }) { + const [copied, setCopied] = useState(false); + + async function onCopy() { + const url = `${window.location.origin}${path}`; + + try { + const html = `${label || 'Download'}`; + const clipboardItem = new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([url], { type: 'text/plain' }), + }); + await navigator.clipboard.write([clipboardItem]); + } catch { + try { + await navigator.clipboard.writeText(url); + } catch { + const helper = document.createElement('textarea'); + helper.value = url; + document.body.appendChild(helper); + helper.select(); + document.execCommand('copy'); + document.body.removeChild(helper); + } + } + + setCopied(true); + await wait(1800); + setCopied(false); + } + + return ( + + ); +} diff --git a/nextjs/app/manage/_components/status-message.js b/nextjs/app/manage/_components/status-message.js new file mode 100644 index 0000000..d4b4134 --- /dev/null +++ b/nextjs/app/manage/_components/status-message.js @@ -0,0 +1,11 @@ +export function StatusMessage({ error, success }) { + if (!error && !success) { + return null; + } + + if (error) { + return
{error}
; + } + + return
{success}
; +} diff --git a/nextjs/app/manage/admin/dashboard/page.js b/nextjs/app/manage/admin/dashboard/page.js new file mode 100644 index 0000000..b92556e --- /dev/null +++ b/nextjs/app/manage/admin/dashboard/page.js @@ -0,0 +1,255 @@ +import { + adminDeleteUploadAction, + adminExtendUploadAction, + adminLogoutAction, +} from '@/src/lib/actions.js'; +import { adminHash } from '@/src/lib/config.js'; +import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { + formatBytes, + formatCountdown, + formatTimestamp, + parseLogDetail, + readSearchParam, +} from '@/src/lib/format.js'; +import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; + +import { CopyLinkButton } from '../../_components/copy-link-button.js'; +import { StatusMessage } from '../../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminDashboardPage({ searchParams }) { + await runCleanupIfNeeded(); + + if (!adminHash) { + return ( +
+
+

Adminzugang nicht konfiguriert

+

Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.

+ + Zurück + +
+
+ ); + } + + await requireAdminUser(); + const csrfToken = await ensureCsrfToken(); + + const [ + activeCount, + activeBytes, + distinctOwners, + totalUploads, + totalDownloads, + totalDeletes, + lastCleanup, + recentLogs, + allUploads, + ] = await Promise.all([ + get('SELECT COUNT(*) AS count FROM uploads'), + get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'), + get('SELECT COUNT(DISTINCT owner) AS count FROM uploads'), + get('SELECT COUNT(*) AS count FROM admin_logs WHERE event = ?', ['upload']), + get('SELECT COALESCE(SUM(downloads), 0) AS count FROM uploads'), + get('SELECT COUNT(*) AS count FROM admin_logs WHERE event IN (?, ?, ?)', [ + 'delete', + 'cleanup', + 'admin_delete', + ]), + get('SELECT MAX(created_at) AS ts FROM admin_logs WHERE event = ?', ['cleanup']), + all( + 'SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 250' + ), + all( + 'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500' + ), + ]); + + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + return ( +
+
+
+

Adminübersicht

+

Metriken, Ereignisse und direkte Eingriffe in Uploads.

+
+ +
+ + Dateimanager + + + Benutzer verwalten + +
+ + +
+
+
+ + + +
+

Statistiken

+
+
+ Aktive Uploads + {activeCount?.count || 0} +
+
+ Aktive Dateigröße + {formatBytes(activeBytes?.total || 0)} +
+
+ Aktive Nutzer + {distinctOwners?.count || 0} +
+
+ Uploads gesamt + {totalUploads?.count || 0} +
+
+ Downloads gesamt + {totalDownloads?.count || 0} +
+
+ Löschungen gesamt + {totalDeletes?.count || 0} +
+
+ Letztes Cleanup + {lastCleanup?.ts ? formatTimestamp(lastCleanup.ts) : '-'} +
+
+
+ +
+

Aktuelle Uploads

+ {allUploads.length === 0 ? ( +

Noch keine Uploads vorhanden.

+ ) : ( +
+ + + + + + + + + + + + + {allUploads.map((item) => { + const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`; + return ( + + + + + + + + + ); + })} + +
NutzerDateiGrößeHochgeladenAblaufAktionen
{item.owner} + {item.original_name} +
{item.stored_name}
+
{formatBytes(item.size_bytes)}{formatTimestamp(item.uploaded_at)} +
{formatTimestamp(item.expires_at)}
+
Noch {formatCountdown(item.expires_at)}
+
+
+ + +
+ + + + +
+ +
+ + + +
+
+
+
+ )} +
+ +
+

Letzte Ereignisse

+ {recentLogs.length === 0 ? ( +

Keine Logs vorhanden.

+ ) : ( +
+ + + + + + + + + + + + + {recentLogs.map((entry, index) => { + const details = parseLogDetail(entry.detail); + return ( + + + + + + + + + ); + })} + +
ZeitEventNutzerIPDetailsUser-Agent
{formatTimestamp(entry.created_at)}{entry.event}{entry.owner || '-'}{entry.ip || '-'} + {details.length === 0 ? ( + - + ) : ( +
+ {details.map((detail) => ( +
+ {detail.key}: {detail.value} +
+ ))} +
+ )} +
{entry.user_agent || '-'}
+
+ )} +
+
+ ); +} diff --git a/nextjs/app/manage/admin/files/page.js b/nextjs/app/manage/admin/files/page.js new file mode 100644 index 0000000..3c2f6f8 --- /dev/null +++ b/nextjs/app/manage/admin/files/page.js @@ -0,0 +1,284 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + adminCopyPathAction, + adminDeletePathAction, + adminLogoutAction, + adminMkdirAction, + adminMovePathAction, + adminRenamePathAction, + adminUploadToPathAction, +} from '@/src/lib/actions.js'; +import { runCleanupIfNeeded } from '@/src/lib/db.js'; +import { adminFilesHref, resolveAdminPath, sanitizeRelativePath } from '@/src/lib/files.js'; +import { formatBytes, formatTimestamp, readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; + +import { StatusMessage } from '../../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +function buildBreadcrumbs(relativePath) { + const segments = relativePath.split('/').filter(Boolean); + const breadcrumbs = []; + let currentPath = ''; + + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + breadcrumbs.push({ + label: segment, + href: adminFilesHref(currentPath), + }); + } + + return breadcrumbs; +} + +export default async function AdminFilesPage({ searchParams }) { + await runCleanupIfNeeded(); + await requireAdminUser(); + + const csrfToken = await ensureCsrfToken(); + const resolvedSearchParams = await searchParams; + const relativePath = sanitizeRelativePath(readSearchParam(resolvedSearchParams, 'path')); + + const queryError = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + const absolutePath = resolveAdminPath(relativePath); + if (!absolutePath) { + return ( +
+
+
+

Admin-Dateimanager

+

Dateien im DATA_DIR verwalten (ausgenommen _share).

+
+ + Zur Adminübersicht + +
+ + +
+ ); + } + + let entries; + try { + entries = await fs.promises.readdir(absolutePath, { withFileTypes: true }); + } catch { + return ( +
+
+
+

Admin-Dateimanager

+

Dateien im DATA_DIR verwalten (ausgenommen _share).

+
+ + Zur Adminübersicht + +
+ + +
+ ); + } + + const visibleEntries = entries.filter((entry) => entry.name !== '_share'); + const details = await Promise.all( + visibleEntries.map(async (entry) => { + const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + const absoluteChildPath = path.join(absolutePath, entry.name); + + let stat = null; + try { + stat = await fs.promises.stat(absoluteChildPath); + } catch { + } + + return { + name: entry.name, + childPath, + isDir: entry.isDirectory(), + size: stat && stat.isFile() ? stat.size : null, + modifiedAt: stat ? stat.mtimeMs : null, + }; + }) + ); + + details.sort((left, right) => { + if (left.isDir !== right.isDir) { + return left.isDir ? -1 : 1; + } + return left.name.localeCompare(right.name, 'de'); + }); + + const parentPathRaw = relativePath ? path.dirname(relativePath) : ''; + const parentPath = parentPathRaw === '.' ? '' : sanitizeRelativePath(parentPathRaw); + const breadcrumbs = buildBreadcrumbs(relativePath); + + return ( +
+
+
+

Admin-Dateimanager

+

Dateien im DATA_DIR verwalten (ausgenommen _share).

+
+ +
+ + Zur Adminübersicht + +
+ + +
+
+
+ + + +
+
+ Pfad: /{relativePath} + {relativePath ? ( + + Eine Ebene zurück + + ) : null} +
+ +
+ Position: + root + {breadcrumbs.map((item) => ( + + {' / '} + {item.label} + + ))} +
+
+ +
+
+

Ordner erstellen

+
+ + + + + + +
+
+ +
+

Datei hochladen

+
+ + + + + + +
+
+
+ +
+

Inhalt

+ {details.length === 0 ? ( +

Keine Einträge in diesem Ordner.

+ ) : ( +
+ + + + + + + + + + + + {details.map((item) => ( + + + + + + + + ))} + +
NameTypGrößeGeändertAktionen
+ {item.isDir ? ( + + {item.name} + + ) : ( + {item.name} + )} +
{item.childPath}
+
{item.isDir ? 'Ordner' : 'Datei'}{item.size != null ? formatBytes(item.size) : '-'}{item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'} +
+
+ + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + +
+
+
+
+ )} +
+
+ ); +} diff --git a/nextjs/app/manage/admin/page.js b/nextjs/app/manage/admin/page.js new file mode 100644 index 0000000..16122fa --- /dev/null +++ b/nextjs/app/manage/admin/page.js @@ -0,0 +1,66 @@ +import { redirect } from 'next/navigation'; + +import { adminLoginAction } from '@/src/lib/actions.js'; +import { adminHash } from '@/src/lib/config.js'; +import { runCleanupIfNeeded } from '@/src/lib/db.js'; +import { readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js'; + +import { StatusMessage } from '../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminLoginPage({ searchParams }) { + await runCleanupIfNeeded(); + + if (!adminHash) { + return ( +
+
+

Adminzugang nicht konfiguriert

+

Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.

+ + Zurück zur Anmeldung + +
+
+ ); + } + + const user = await getAuthenticatedUser(); + if (user?.admin) { + redirect('/manage/admin/dashboard'); + } + + const csrfToken = await ensureCsrfToken(); + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + return ( +
+
+
+

Adminbereich

+

Melde dich als Administrator an.

+
+
+ +
+ +
+ + + + + +
+
+
+ ); +} diff --git a/nextjs/app/manage/admin/users/page.js b/nextjs/app/manage/admin/users/page.js new file mode 100644 index 0000000..5908c58 --- /dev/null +++ b/nextjs/app/manage/admin/users/page.js @@ -0,0 +1,124 @@ +import { + adminCreateUserAction, + adminDeleteUserAction, + adminLogoutAction, + adminResetUserAction, +} from '@/src/lib/actions.js'; +import { all, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { formatTimestamp, readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; + +import { StatusMessage } from '../../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminUsersPage({ searchParams }) { + await runCleanupIfNeeded(); + await requireAdminUser(); + + const csrfToken = await ensureCsrfToken(); + const users = await all('SELECT username, created_at FROM users ORDER BY username ASC'); + + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + return ( +
+
+
+

Benutzerverwaltung

+

Konten erstellen, Passwort setzen oder Benutzer entfernen.

+
+ +
+ + Zur Adminübersicht + +
+ + +
+
+
+ + + +
+

Neuen Benutzer anlegen

+
+ + + + + + + +
+
+ +
+

Bestehende Benutzer

+ {users.length === 0 ? ( +

Noch keine Benutzer vorhanden.

+ ) : ( +
+ + + + + + + + + + {users.map((user) => ( + + + + + + ))} + +
BenutzernameErstelltAktionen
{user.username}{formatTimestamp(user.created_at)} +
+
+ + + + +
+ +
+ + + +
+
+
+
+ )} +
+
+ ); +} diff --git a/nextjs/app/manage/dashboard/page.js b/nextjs/app/manage/dashboard/page.js new file mode 100644 index 0000000..00a6c90 --- /dev/null +++ b/nextjs/app/manage/dashboard/page.js @@ -0,0 +1,149 @@ +import { + deleteOwnUploadAction, + extendOwnUploadAction, + uploadFileAction, + userLogoutAction, +} from '@/src/lib/actions.js'; +import { all, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { formatBytes, formatCountdown, formatTimestamp, readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js'; + +import { CopyLinkButton } from '../_components/copy-link-button.js'; +import { StatusMessage } from '../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function DashboardPage({ searchParams }) { + await runCleanupIfNeeded(); + + const user = await requireAuthenticatedUser(); + const csrfToken = await ensureCsrfToken(); + const uploads = await all( + 'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC', + [user.username] + ); + + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + return ( +
+
+
+

Dateiverwaltung

+

Angemeldet als {user.username}

+
+ +
+ {user.admin ? ( + + Adminbereich + + ) : null} + +
+ + +
+
+
+ +
+

Neue Datei hochladen

+ + +
+ + + + + + + +
+
+ +
+

Aktuelle Uploads

+ {uploads.length === 0 ? ( +

Noch keine Uploads.

+ ) : ( +
+ + + + + + + + + + + + {uploads.map((item) => { + const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`; + return ( + + + + + + + + ); + })} + +
DateiGrößeHochgeladenAblaufAktionen
+ {item.original_name} +
{item.stored_name}
+
{formatBytes(item.size_bytes)}{formatTimestamp(item.uploaded_at)} +
{formatTimestamp(item.expires_at)}
+
Noch {formatCountdown(item.expires_at)}
+
+
+ + +
+ + + + +
+ +
+ + + +
+
+
+
+ )} +
+
+ ); +} diff --git a/nextjs/app/manage/login/page.js b/nextjs/app/manage/login/page.js new file mode 100644 index 0000000..4cd1d1b --- /dev/null +++ b/nextjs/app/manage/login/page.js @@ -0,0 +1,65 @@ +import { redirect } from 'next/navigation'; + +import { userLoginAction } from '@/src/lib/actions.js'; +import { runCleanupIfNeeded } from '@/src/lib/db.js'; +import { readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js'; + +import { StatusMessage } from '../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function LoginPage({ searchParams }) { + await runCleanupIfNeeded(); + + const user = await getAuthenticatedUser(); + if (user) { + redirect('/manage/dashboard'); + } + + const csrfToken = await ensureCsrfToken(); + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + + return ( +
+
+
+

Dateiverwaltung

+

Melde dich an, um Uploads zu verwalten.

+
+ + Admin-Anmeldung + +
+ +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/nextjs/app/manage/page.js b/nextjs/app/manage/page.js new file mode 100644 index 0000000..ee9ca5a --- /dev/null +++ b/nextjs/app/manage/page.js @@ -0,0 +1,21 @@ +import { redirect } from 'next/navigation'; + +import { runCleanupIfNeeded } from '@/src/lib/db.js'; +import { getAuthenticatedUser } from '@/src/lib/security.js'; + +export const dynamic = 'force-dynamic'; + +export default async function ManageIndexPage() { + await runCleanupIfNeeded(); + + const user = await getAuthenticatedUser(); + if (!user) { + redirect('/manage/login'); + } + + if (user.admin) { + redirect('/manage/admin/dashboard'); + } + + redirect('/manage/dashboard'); +} diff --git a/nextjs/app/not-found.js b/nextjs/app/not-found.js new file mode 100644 index 0000000..3fdbf51 --- /dev/null +++ b/nextjs/app/not-found.js @@ -0,0 +1,13 @@ +export default function NotFoundPage() { + return ( +
+
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht oder wurde verschoben.

+ + Zurück zur Anmeldung + +
+
+ ); +} diff --git a/nextjs/app/page.js b/nextjs/app/page.js new file mode 100644 index 0000000..19960ba --- /dev/null +++ b/nextjs/app/page.js @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function HomePage() { + redirect('/manage'); +} diff --git a/nextjs/jsconfig.json b/nextjs/jsconfig.json new file mode 100644 index 0000000..f705409 --- /dev/null +++ b/nextjs/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + } + } +} diff --git a/nextjs/next.config.mjs b/nextjs/next.config.mjs new file mode 100644 index 0000000..af0bbe9 --- /dev/null +++ b/nextjs/next.config.mjs @@ -0,0 +1,25 @@ +/** @type {import('next').NextConfig} */ +const uploadMaxBytes = Number.parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); +const actionBodySizeLimit = Number.isFinite(uploadMaxBytes) && uploadMaxBytes > 0 + ? `${uploadMaxBytes}` + : '1gb'; + +const nextConfig = { + poweredByHeader: false, + experimental: { + serverActions: { + bodySizeLimit: actionBodySizeLimit, + }, + }, + serverExternalPackages: ['sqlite3'], + async headers() { + return [ + { + source: '/:path*', + headers: [{ key: 'X-Content-Type-Options', value: 'nosniff' }], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/expressjs/package-lock.json b/nextjs/package-lock.json similarity index 67% rename from expressjs/package-lock.json rename to nextjs/package-lock.json index 54ac068..81bedc2 100644 --- a/expressjs/package-lock.json +++ b/nextjs/package-lock.json @@ -1,22 +1,31 @@ { - "name": "files-lehnert-express", + "name": "files-lehnert-next", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "files-lehnert-express", + "name": "files-lehnert-next", "version": "1.0.0", "dependencies": { "bcryptjs": "^2.4.3", - "cookie-parser": "^1.4.6", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1", + "jsonwebtoken": "^9.0.3", + "next": "^16.2.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", "sqlite3": "^5.1.7" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -24,6 +33,606 @@ "license": "MIT", "optional": true }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -63,6 +672,15 @@ "node": ">=10" } }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -80,19 +698,6 @@ "license": "ISC", "optional": true }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -168,12 +773,6 @@ "node": ">=8" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -211,12 +810,6 @@ "node": ">= 6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -244,6 +837,18 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -284,30 +889,6 @@ "node": ">= 6" } }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -349,32 +930,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -418,34 +973,25 @@ "node": ">=10" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, "node_modules/chownr": { "version": "2.0.0", @@ -466,6 +1012,12 @@ "node": ">=6" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -483,21 +1035,6 @@ "license": "MIT", "optional": true }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -505,70 +1042,6 @@ "license": "ISC", "optional": true }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -600,25 +1073,6 @@ "license": "MIT", "optional": true }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -628,32 +1082,6 @@ "node": ">=8" } }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -663,12 +1091,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -676,15 +1098,6 @@ "license": "MIT", "optional": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -734,51 +1147,6 @@ "license": "MIT", "optional": true }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -788,94 +1156,12 @@ "node": ">=6" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -901,15 +1187,6 @@ "license": "ISC", "optional": true }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -931,43 +1208,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -996,18 +1236,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1015,18 +1243,6 @@ "license": "ISC", "optional": true }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1034,18 +1250,6 @@ "license": "ISC", "optional": true }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -1053,26 +1257,6 @@ "license": "BSD-2-Clause", "optional": true }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -1162,18 +1346,6 @@ "ms": "^2.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1255,15 +1427,6 @@ "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1281,12 +1444,6 @@ "license": "MIT", "optional": true }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1426,75 +1583,6 @@ "node": ">= 10" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1624,18 +1712,6 @@ "node": ">= 8" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1646,25 +1722,25 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">= 6.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/napi-build-utils": { @@ -1678,10 +1754,64 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6" } }, + "node_modules/next": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.1", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", @@ -1758,39 +1888,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1816,15 +1913,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1835,11 +1923,39 @@ "node": ">=0.10.0" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } }, "node_modules/prebuild-install": { "version": "7.1.3", @@ -1867,12 +1983,6 @@ "node": ">=10" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -1894,19 +2004,6 @@ "node": ">=10" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1917,45 +2014,6 @@ "once": "^1.3.1" } }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1971,26 +2029,26 @@ "rc": "cli.js" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } }, "node_modules/retry": { "version": "0.12.0", @@ -2043,6 +2101,13 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -2057,51 +2122,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -2109,82 +2129,49 @@ "license": "ISC", "optional": true }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { - "node": ">= 0.4" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "url": "https://opencollective.com/libvips" }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/signal-exit": { @@ -2305,6 +2292,15 @@ "license": "MIT", "optional": true }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -2342,23 +2338,6 @@ "node": ">= 8" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -2411,6 +2390,29 @@ "node": ">=0.10.0" } }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -2497,14 +2499,11 @@ "node": ">=10" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -2518,25 +2517,6 @@ "node": "*" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", @@ -2557,39 +2537,12 @@ "imurmurhash": "^0.1.4" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2622,15 +2575,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/nextjs/package.json b/nextjs/package.json new file mode 100644 index 0000000..d47bfe8 --- /dev/null +++ b/nextjs/package.json @@ -0,0 +1,18 @@ +{ + "name": "files-lehnert-next", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.3", + "next": "^16.2.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "sqlite3": "^5.1.7" + } +} diff --git a/nextjs/proxy.js b/nextjs/proxy.js new file mode 100644 index 0000000..6bacba3 --- /dev/null +++ b/nextjs/proxy.js @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +const csrfCookieName = 'csrf'; +const cookieSecure = process.env.COOKIE_SECURE === 'true'; + +function createToken() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export function proxy(request) { + const token = request.cookies.get(csrfCookieName)?.value; + if (token) { + return NextResponse.next(); + } + + const nextToken = createToken(); + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-csrf-token', nextToken); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + response.cookies.set(csrfCookieName, nextToken, { + httpOnly: true, + sameSite: 'strict', + secure: cookieSecure, + path: '/', + }); + + return response; +} + +export const config = { + matcher: ['/manage/:path*'], +}; diff --git a/nextjs/src/lib/actions.js b/nextjs/src/lib/actions.js new file mode 100644 index 0000000..adb13e7 --- /dev/null +++ b/nextjs/src/lib/actions.js @@ -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.'); +} diff --git a/nextjs/src/lib/config.js b/nextjs/src/lib/config.js new file mode 100644 index 0000000..ef5b29a --- /dev/null +++ b/nextjs/src/lib/config.js @@ -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'; diff --git a/nextjs/src/lib/db.js b/nextjs/src/lib/db.js new file mode 100644 index 0000000..bb1deb8 --- /dev/null +++ b/nextjs/src/lib/db.js @@ -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); diff --git a/nextjs/src/lib/files.js b/nextjs/src/lib/files.js new file mode 100644 index 0000000..a945f9a --- /dev/null +++ b/nextjs/src/lib/files.js @@ -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)}`; +} diff --git a/nextjs/src/lib/format.js b/nextjs/src/lib/format.js new file mode 100644 index 0000000..37968eb --- /dev/null +++ b/nextjs/src/lib/format.js @@ -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 ''; +} diff --git a/nextjs/src/lib/security.js b/nextjs/src/lib/security.js new file mode 100644 index 0000000..bb210be --- /dev/null +++ b/nextjs/src/lib/security.js @@ -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); +}