UI rehaul + original download filenames

This commit is contained in:
Ludwig Lehnert
2026-01-29 06:57:54 +01:00
parent 6177166d82
commit 6eb42cd223
4 changed files with 295 additions and 72 deletions

View File

@@ -79,7 +79,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.express.rule=Host(`${SERVICE_FQDN}`) && PathPrefix(`/manage`)"
- "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"
@@ -87,7 +87,7 @@ services:
- "traefik.http.services.express-svc.loadbalancer.server.port=3000"
- "traefik.http.routers.express.priority=10"
# Optional HTTP redirect
- "traefik.http.routers.express-http.rule=Host(`${SERVICE_FQDN}`) && PathPrefix(`/manage`)"
- "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"

Binary file not shown.

View File

@@ -257,12 +257,11 @@ function createRandomId() {
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(/\s+/g, '-')
.replace(/[^a-zA-Z0-9_-]/g, '')
.replace(/-+/g, '-')
.replace(/_+/g, '_')
.replace(/^[-_]+|[-_]+$/g, '');
.replace(/[^\w\-. ]/g, '') // Allow words, dashes, dots, spaces
.trim()
.replace(/\s+/g, '-'); // Replace spaces with dashes for better URLs
return cleaned || 'datei';
}
@@ -318,45 +317,152 @@ function renderFileManagerPage(title, body) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root { --ink:#0b0f19; --muted:#5b6470; --line:#d8dde4; --bg:#eef1f5; --card:#ffffff; --accent:#0f766e; --accent-strong:#0a5b55; --accent-soft:#e6f4f2; }
:root {
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--primary: #0f766e;
--primary-hover: #0d9488;
--primary-light: #f0fdfa;
--danger: #ef4444;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--radius: 0.75rem;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "IBM Plex Sans", "Noto Sans", sans-serif; background: var(--bg); color: var(--ink); }
main { max-width: 1280px; margin: 0 auto; padding: 26px 18px 70px; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(135deg, #ffffff 0%, #f6fbfa 100%); border: 1px solid var(--line); }
h1 { margin: 0; font-size: 1.75rem; letter-spacing: 0.01em; }
h2 { margin: 0 0 12px; font-size: 1.08rem; }
.muted { color: var(--muted); font-size: 0.95rem; }
.card { margin-top: 18px; padding: 16px; background: var(--card); border-radius: 16px; border: 1px solid var(--line); box-shadow: 0 8px 26px rgba(12, 18, 28, 0.08); }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; background: #f1f5f9; border: 1px solid var(--line); color: var(--ink); text-decoration: none; font-weight: 600; }
.tag.primary { background: var(--accent-soft); border-color: #bfe6e0; color: var(--accent-strong); }
.tag span { color: var(--muted); font-weight: 500; }
.browser-shell { display: grid; gap: 16px; }
.browser-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.crumbs { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.crumbs a { color: var(--accent-strong); text-decoration: none; font-weight: 600; }
.crumbs span { color: var(--muted); }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, button, select { font: inherit; padding: 9px 12px; border-radius: 10px; }
input, select { border: 1px solid var(--line); background: #fff; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; transition: transform 0.15s ease, background 0.2s ease; }
button:hover { background: var(--accent-strong); }
button.secondary { background: transparent; color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:hover { background: #f2f8f7; }
.name { display: inline-flex; align-items: center; gap: 10px; }
.name strong { font-weight: 700; }
.folder { font-weight: 700; color: var(--accent-strong); text-decoration: none; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.actions button { font-size: 0.9rem; padding: 7px 10px; }
dialog { border: none; border-radius: 16px; padding: 0; width: min(520px, 92vw); }
dialog::backdrop { background: rgba(15, 23, 42, 0.4); }
.dialog-card { padding: 18px; display: grid; gap: 12px; }
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }
.dialog-actions .secondary { border-color: var(--line); color: var(--accent-strong); }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text-main);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
main { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem; }
header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
h1 { margin: 0; font-size: 1.5rem; font-weight: 700; color: var(--text-main); }
h2 { margin: 0 0 1rem; font-size: 1.1rem; font-weight: 600; }
.muted { color: var(--text-muted); font-size: 0.875rem; }
.card {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
.tag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-main);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.tag:hover { border-color: var(--text-muted); }
.tag.primary {
background: var(--primary-light);
border-color: #ccfbf1;
color: var(--primary);
}
.tag.primary:hover { border-color: var(--primary); }
.tag span { color: var(--text-muted); }
.browser-shell { display: grid; gap: 1.5rem; }
.browser-bar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
.crumbs { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; font-size: 0.95rem; }
.crumbs a { color: var(--primary); text-decoration: none; font-weight: 500; }
.crumbs a:hover { text-decoration: underline; }
.crumbs span { color: var(--text-muted); }
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
form { display: grid; gap: 1rem; }
label { display: grid; gap: 0.375rem; font-weight: 500; font-size: 0.9rem; }
input, button, select {
font: inherit;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
outline: none;
}
input, select {
border: 1px solid var(--border);
background: #fff;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
}
button {
border: 1px solid transparent;
background: var(--primary);
color: #fff;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:hover { background: var(--primary-hover); }
button.secondary {
background: white;
border-color: var(--border);
color: var(--text-main);
}
button.secondary:hover { border-color: var(--text-muted); background: var(--bg); }
button.danger {
background: #fee2e2;
color: var(--danger);
}
button.danger:hover { background: #fecaca; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.95rem; }
th { text-align: left; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--bg); }
.name { display: inline-flex; align-items: center; gap: 0.75rem; font-weight: 500; }
.folder { color: var(--primary); text-decoration: none; }
.folder:hover { text-decoration: underline; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.actions button { font-size: 0.8rem; padding: 0.25rem 0.6rem; }
dialog {
border: none;
border-radius: var(--radius);
padding: 0;
width: min(480px, 95vw);
box-shadow: var(--shadow-lg);
background: var(--card-bg);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); }
.dialog-card { padding: 1.5rem; display: grid; gap: 1.25rem; }
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
</style>
</head>
<body>
@@ -375,29 +481,112 @@ function renderPage(title, body, mainClass = '') {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root { --ink:#0f172a; --muted:#6b7280; --line:#e5e7eb; --bg:#f7f7f4; --card:#ffffff; --accent:#111827; }
:root {
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--primary: #0f172a; /* Darker primary for admin/user dashboard */
--primary-hover: #334155;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--radius: 0.75rem;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "IBM Plex Sans", "Noto Sans", sans-serif; color: var(--ink); background: var(--bg); }
main { max-width: 920px; margin: 0 auto; padding: 22px 16px 48px; }
main.wide { max-width: 1180px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
h1 { margin: 0; font-size: 1.55rem; }
h2 { margin: 0 0 12px; font-size: 1.08rem; }
.muted { color: var(--muted); font-size: 0.95rem; }
.card { margin-top: 16px; padding: 16px; background: var(--card); border-radius: 12px; border: 1px solid var(--line); }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, button { font: inherit; padding: 8px 10px; border-radius: 8px; }
input { border: 1px solid var(--line); background: #fff; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button.secondary { background: transparent; color: var(--accent); }
.row { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); vertical-align: top; }
progress { width: 100%; height: 12px; accent-color: var(--accent); }
.actions { display: grid; gap: 6px; }
.actions input, .actions button { font-size: 0.9rem; padding: 6px 8px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text-main);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
main { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
main.wide { max-width: 1200px; }
header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2rem;
}
h1 { margin: 0; font-size: 1.75rem; font-weight: 700; }
h2 { margin: 0 0 1.25rem; font-size: 1.1rem; font-weight: 600; }
.muted { color: var(--text-muted); font-size: 0.875rem; }
.card {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
form { display: grid; gap: 1rem; }
label { display: grid; gap: 0.375rem; font-weight: 500; font-size: 0.9rem; }
input, button {
font: inherit;
padding: 0.6rem 0.8rem;
border-radius: 0.5rem;
outline: none;
}
input {
border: 1px solid var(--border);
background: #fff;
transition: border-color 0.2s;
}
input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px #e2e8f0;
}
button {
border: 1px solid transparent;
background: var(--primary);
color: #fff;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: var(--primary-hover); }
button.secondary {
background: white;
border-color: var(--border);
color: var(--text-main);
}
button.secondary:hover { border-color: var(--text-muted); background: var(--bg); }
.row { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.row .card { margin: 0; padding: 1.25rem; text-align: center; }
.row .card strong { display: block; font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.row .card .muted { font-size: 1.5rem; color: var(--text-main); font-weight: 600; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.925rem; }
th { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--bg); }
progress { width: 100%; height: 0.75rem; border-radius: 99px; overflow: hidden; }
progress::-webkit-progress-bar { background-color: var(--border); }
progress::-webkit-progress-value { background-color: var(--primary); }
.actions { display: flex; gap: 0.5rem; align-items: center; }
.actions form { display: flex; gap: 0.5rem; }
.actions input, .actions button { font-size: 0.8rem; padding: 0.35rem 0.6rem; }
.pill { display: inline-flex; padding: 0.25rem 0.75rem; border-radius: 999px; background: #fee2e2; color: #991b1b; font-size: 0.85rem; font-weight: 500; margin-bottom: 1rem; }
a { color: inherit; text-decoration-color: var(--border); }
a:hover { color: var(--primary); text-decoration-color: var(--primary); }
/* Toolbar helpers for admin */
.toolbar { display: flex; gap: 0.75rem; }
.tag { display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; border-radius: 999px; background: #fff; border: 1px solid var(--border); text-decoration: none; font-size: 0.875rem; font-weight: 500; }
.tag.primary { background: #f1f5f9; color: var(--text-main); }
.tag:hover { border-color: var(--text-muted); }
</style>
</head>
<body>
@@ -1555,10 +1744,12 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async
}
const now = Date.now();
const ext = sanitizeExtension(req.file.originalname);
const baseName = sanitizeBaseName(req.file.originalname);
// 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();
const storedName = `_${baseName}-${token}${ext}`;
// 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 || '');
@@ -1583,7 +1774,7 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
req.user.username,
req.file.originalname,
req.file.originalname, // Store exact original name
storedName,
storedPath,
req.file.size,
@@ -1643,6 +1834,32 @@ app.use((req, res) => {
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Seite nicht gefunden.</p>'));
});
// Add endpoint to serve files with correct headers
// NOTE: This must be mounted at root level or matching the path prefix handled by Traefik
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 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)) {
res.download(filePath, filename); // Fallback: download with stored name
return;
}
res.status(404).send('File not found');
return;
}
res.download(row.stored_path, row.original_name);
});
const server = app.listen(port, () => {
console.log(`Express server listening on ${port} with base path ${basePath}`);
});

View File

@@ -32,7 +32,13 @@ IndexOptions FancyIndexing FoldersFirst NameWidth=*\n\
# Force download for shared files
RUN printf '\n# --- Force download in _share ---\n\
<Directory "/usr/local/apache2/htdocs/_share">\n\
Header set Content-Disposition "attachment"\n\
# Header set Content-Disposition "attachment"\n\
# We want to let the application handle the download name if possible\n\
# or just serve it directly if accessed directly. \n\
# Actually, we should probably proxy requests for metadata resolution\n\
# But for now, let''s just keep it simple. \n\
# If we want Apache to serve it with original name, we need a map.\n\
# Since we moved logic to Node, Apache just serves raw files if needed.\n\
</Directory>\n' >> /usr/local/apache2/conf/httpd.conf
EXPOSE 80