Files
simple-web-file-upload/public/upload.html
T
Stefan Hacker 0770259d3d Add file upload portal with per-customer links and WebDAV admin access
- Customer upload via token link (no login), optional password + expiry,
  drag & drop for files and folders with preserved structure
- Admin portal with setup wizard, role-based users (admin/staff),
  per-customer WebDAV access rules (read/write), session auth
- WebDAV container (Debian apache2) with htpasswd + access.conf
  auto-generated from the SQLite DB and reloaded via inotifywait
- Configurable public base URL and janitor cron interval in admin UI;
  janitor reconciles the uploads table with the filesystem

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:00:51 +02:00

192 lines
6.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Datei-Upload</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: .25rem; }
.muted { color: #888; font-size: .9rem; }
.drop {
margin-top: 1.5rem; border: 2px dashed #888; border-radius: 12px;
padding: 3rem 1rem; text-align: center; transition: background .15s, border-color .15s;
}
.drop.drag { background: rgba(0,120,255,.08); border-color: #0078ff; }
.buttons { margin-top: 1rem; display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button, label.btn {
padding: .6rem 1rem; border-radius: 8px; border: 1px solid #888;
cursor: pointer; background: transparent; font: inherit;
}
label.btn input { display: none; }
#list { margin-top: 1.5rem; }
.file { display: flex; justify-content: space-between; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid #333; font-size: .9rem; }
.file .status { font-variant-numeric: tabular-nums; }
.ok { color: #2ecc71; }
.err { color: #e74c3c; }
.gate { margin-top: 1rem; display: none; }
.gate input { padding: .5rem; border-radius: 6px; border: 1px solid #888; background: transparent; color: inherit; }
progress { width: 100%; height: 8px; }
</style>
</head>
<body>
<h1 id="title">Datei-Upload</h1>
<div class="muted" id="info"></div>
<div class="gate" id="gate">
<p>Dieser Link ist passwortgeschützt.</p>
<input type="password" id="pw" placeholder="Passwort" />
<button id="pwBtn">Entsperren</button>
<div id="pwErr" class="err" style="display:none;margin-top:.5rem">Passwort falsch.</div>
</div>
<div id="main" style="display:none">
<div class="drop" id="drop">
<div>Dateien oder Ordner hier hineinziehen</div>
<div class="muted">oder</div>
<div class="buttons">
<label class="btn">Dateien wählen<input type="file" id="fileInput" multiple /></label>
<label class="btn">Ordner wählen<input type="file" id="dirInput" webkitdirectory multiple /></label>
</div>
</div>
<div id="list"></div>
</div>
<script>
const token = location.pathname.split('/').filter(Boolean)[1];
let password = '';
const info = document.getElementById('info');
const gate = document.getElementById('gate');
const main = document.getElementById('main');
const drop = document.getElementById('drop');
const list = document.getElementById('list');
async function init() {
const r = await fetch(`/u/${token}/info`);
if (!r.ok) { document.body.innerHTML = '<h1>Link ungültig oder abgelaufen.</h1>'; return; }
const data = await r.json();
document.getElementById('title').textContent = `Upload für ${data.name}`;
if (data.expires_at) {
info.textContent = `Gültig bis: ${new Date(data.expires_at).toLocaleString()}`;
}
if (data.has_password) gate.style.display = 'block';
else main.style.display = 'block';
}
document.getElementById('pwBtn').onclick = async () => {
const pw = document.getElementById('pw').value;
const r = await fetch(`/u/${token}/auth`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ password: pw }),
});
const j = await r.json();
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; }
else document.getElementById('pwErr').style.display='block';
};
function fmtSize(n) {
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function addRow(name, size) {
const row = document.createElement('div');
row.className = 'file';
row.innerHTML = `<div>${name}</div><div class="status">${fmtSize(size)} <span>wartet</span><progress max="100" value="0"></progress></div>`;
list.appendChild(row);
return row;
}
async function uploadOne(file, relPath) {
const row = addRow(relPath, file.size);
const status = row.querySelector('.status span');
const bar = row.querySelector('progress');
const fd = new FormData();
// path first, so multer has it available when processing the file
fd.append('path', relPath);
fd.append('file', file, file.name);
await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/u/${token}/upload`);
if (password) xhr.setRequestHeader('X-Upload-Password', password);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
status.textContent = 'fertig';
status.className = 'ok';
bar.value = 100;
} else {
status.textContent = 'Fehler';
status.className = 'err';
}
resolve();
};
xhr.onerror = () => { status.textContent='Fehler'; status.className='err'; resolve(); };
xhr.send(fd);
});
}
async function uploadFiles(items) {
for (const { file, path } of items) {
await uploadOne(file, path);
}
}
document.getElementById('fileInput').onchange = (e) => {
const items = [...e.target.files].map(f => ({ file: f, path: f.name }));
uploadFiles(items);
};
document.getElementById('dirInput').onchange = (e) => {
const items = [...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name }));
uploadFiles(items);
};
// Drag & drop with directory support
async function traverse(entry, prefix='') {
const out = [];
if (entry.isFile) {
const file = await new Promise(r => entry.file(r));
out.push({ file, path: prefix + entry.name });
} else if (entry.isDirectory) {
const reader = entry.createReader();
const entries = await new Promise(r => reader.readEntries(r));
for (const e of entries) {
const sub = await traverse(e, prefix + entry.name + '/');
out.push(...sub);
}
}
return out;
}
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', async (e) => {
e.preventDefault();
drop.classList.remove('drag');
const items = [...e.dataTransfer.items];
const all = [];
for (const it of items) {
const entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
if (entry) {
const sub = await traverse(entry);
all.push(...sub);
} else if (it.kind === 'file') {
const f = it.getAsFile();
all.push({ file: f, path: f.name });
}
}
uploadFiles(all);
});
init();
</script>
</body>
</html>