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>
This commit is contained in:
Stefan Hacker
2026-04-16 11:00:51 +02:00
parent 16795137d5
commit 0770259d3d
16 changed files with 1733 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const db = require('./db');
const WEBDAV_CONFIG_DIR = process.env.WEBDAV_CONFIG_DIR || '/webdav-config';
function ensureDir() {
fs.mkdirSync(WEBDAV_CONFIG_DIR, { recursive: true });
}
function atomicWrite(filepath, content) {
const tmp = filepath + '.tmp';
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filepath);
}
// Apache mod_authn_file accepts bcrypt hashes ($2a$/$2b$/$2y$),
// which is exactly what bcrypt npm produces. No re-hashing needed.
function buildHtpasswd() {
const users = db.prepare('SELECT username, password_hash FROM users ORDER BY username').all();
return users.map(u => `${u.username}:${u.password_hash}`).join('\n') + (users.length ? '\n' : '');
}
function buildAccessConf() {
const admins = db.prepare("SELECT username FROM users WHERE role = 'admin' ORDER BY username")
.all().map(r => r.username);
const customers = db.prepare('SELECT * FROM customers ORDER BY slug').all();
let out = '# Auto-generated by app — do not edit.\n\n';
// Root listing auth is already enforced by <Directory "/data/uploads"> in webdav.conf.
// Adding a broad <Location "/"> here would shadow /icons/ and break autoindex graphics.
for (const c of customers) {
const assigns = db.prepare(`
SELECT u.username, ca.access FROM customer_access ca
JOIN users u ON u.id = ca.user_id
WHERE ca.customer_id = ?
ORDER BY u.username
`).all(c.id);
const staffRead = assigns.map(a => a.username);
const staffWrite = assigns.filter(a => a.access === 'write').map(a => a.username);
const readUsers = [...new Set([...admins, ...staffRead])];
const writeUsers = [...new Set([...admins, ...staffWrite])];
const locPath = `/${c.slug}/`;
out += `<Location "${locPath}">\n`;
const sameSet = readUsers.length === writeUsers.length &&
readUsers.every(u => writeUsers.includes(u));
if (sameSet) {
// Same users for read and write — one Require covers it all.
if (readUsers.length) out += ` Require user ${readUsers.join(' ')}\n`;
else out += ` Require all denied\n`;
} else {
// Split explicitly by method so Apache's default RequireAny (OR)
// doesn't let readers inherit write access from a broader outer Require.
out += ` <Limit GET PROPFIND OPTIONS HEAD>\n`;
if (readUsers.length) out += ` Require user ${readUsers.join(' ')}\n`;
else out += ` Require all denied\n`;
out += ` </Limit>\n`;
out += ` <LimitExcept GET PROPFIND OPTIONS HEAD>\n`;
if (writeUsers.length) out += ` Require user ${writeUsers.join(' ')}\n`;
else out += ` Require all denied\n`;
out += ` </LimitExcept>\n`;
}
out += `</Location>\n\n`;
}
return out;
}
function sync() {
try {
ensureDir();
atomicWrite(path.join(WEBDAV_CONFIG_DIR, 'htpasswd'), buildHtpasswd());
atomicWrite(path.join(WEBDAV_CONFIG_DIR, 'access.conf'), buildAccessConf());
// reload trigger (watched by apache entrypoint)
atomicWrite(path.join(WEBDAV_CONFIG_DIR, 'reload.trigger'), String(Date.now()));
} catch (e) {
console.error('webdav-config sync failed:', e.message);
}
}
module.exports = { sync, WEBDAV_CONFIG_DIR };