feat: Synology WebDAV Integration - Admin-Einstellungen + Datei-Spiegelung

This commit is contained in:
2026-06-14 00:19:00 +02:00
parent d29c3b5530
commit d564323c00
4 changed files with 340 additions and 2 deletions

View File

@@ -170,4 +170,60 @@ router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
res.json({ hidden: newHidden });
});
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
router.get('/webdav', authenticate, requireAdmin, (req, res) => {
const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || '';
res.json({
url: get('webdav_url'),
user: get('webdav_user'),
password: get('webdav_password'),
basePath: get('webdav_base_path') || '/dickendock',
enabled: get('webdav_enabled') === '1',
});
});
router.put('/webdav', authenticate, requireAdmin, (req, res) => {
const { url, user, password, basePath, enabled } = req.body;
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || '');
set('webdav_url', url);
set('webdav_user', user);
set('webdav_password', password);
set('webdav_base_path', basePath || '/dickendock');
set('webdav_enabled', enabled ? '1' : '0');
res.json({ ok: true });
});
router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => {
// Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert)
const { url, user, password, basePath } = req.body;
try {
const https = require('https');
const http = require('http');
const base = new URL(url);
const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/');
const mod = base.protocol === 'https:' ? https : http;
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
await new Promise((resolve, reject) => {
const req2 = mod.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: testPath,
method: 'PROPFIND',
headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' },
rejectUnauthorized: false,
timeout: 8000,
}, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); });
req2.on('error', reject);
req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); });
req2.write('<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>');
req2.end();
});
res.json({ ok: true });
} catch(e) {
res.status(502).json({ error: e.message });
}
});
module.exports = router;

View File

@@ -8,13 +8,17 @@ const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const webdav = require('./webdav');
const getSetting = key => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value;
const storage = multer.diskStorage({
const diskStorage = multer.diskStorage({
destination: UPLOAD_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`),
});
const memStorage = multer.memoryStorage();
const storage = diskStorage; // Standard: Disk
const getUpload = () => multer({
storage,
limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 },
@@ -194,7 +198,19 @@ router.post('/', authenticate, (req, res) => {
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
.run(uid, req.file.filename, req.file.originalname, req.file.mimetype||'application/octet-stream', req.file.size, folderId);
res.json(db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid));
const newFile = db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid);
// WebDAV: Datei asynchron spiegeln (Fehler ignorieren, lokale Kopie bleibt)
const cfg = webdav.getConfig();
if (cfg.enabled) {
const user = db.prepare('SELECT username FROM users WHERE id=?').get(uid);
const davPath = webdav.userPath(cfg, user.username) + '/' + req.file.originalname;
fs.readFile(path.join(UPLOAD_DIR, req.file.filename), (err, buf) => {
if (!err) webdav.uploadFile(buf, davPath, req.file.mimetype).catch(e => console.error('[webdav upload]', e.message));
});
}
res.json(newFile);
});
});

View File

@@ -0,0 +1,165 @@
/**
* WebDAV Storage Backend für Synology NAS
* Abstrahiert alle Datei-Operationen lokal oder WebDAV
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const db = require('../../db');
function getSetting(key) {
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value || '';
}
function getConfig() {
return {
url: getSetting('webdav_url'), // z.B. http://192.168.1.100:5005
user: getSetting('webdav_user'),
password: getSetting('webdav_password'),
basePath: getSetting('webdav_base_path') || '/dickendock', // z.B. /dickendock
enabled: getSetting('webdav_enabled') === '1',
};
}
function authHeader(cfg) {
return 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64');
}
// WebDAV Request ausführen
function davRequest(method, davPath, opts = {}) {
return new Promise((resolve, reject) => {
const cfg = getConfig();
const base = new URL(cfg.url);
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
const mod = base.protocol === 'https:' ? https : http;
const headers = {
'Authorization': authHeader(cfg),
'Accept': '*/*',
...opts.headers,
};
const reqOpts = {
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: fullPath,
method,
headers,
rejectUnauthorized: false, // Synology Self-Signed Certs
};
if (opts.body) {
headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const req = mod.request(reqOpts, res => {
let data = '';
res.setEncoding('binary');
res.on('data', d => data += d);
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
// ── Öffentliche API ────────────────────────────────────────────────────────
/**
* Ordner auf WebDAV anlegen (inkl. alle Parent-Ordner)
*/
async function mkdirp(davPath) {
const parts = davPath.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current += '/' + part;
const r = await davRequest('MKCOL', current);
if (r.status !== 201 && r.status !== 405) { // 405 = schon vorhanden
throw new Error(`MKCOL ${current}${r.status}`);
}
}
}
/**
* User-Basispfad auf WebDAV: /dickendock/{username}
*/
function userPath(cfg, username) {
return cfg.basePath.replace(/\/$/, '') + '/' + username.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/**
* Datei auf WebDAV hochladen
* @param {Buffer} buffer - Dateiinhalt
* @param {string} davPath - Zielpfad auf WebDAV
* @param {string} mimeType
*/
async function uploadFile(buffer, davPath, mimeType = 'application/octet-stream') {
await mkdirp(davPath.split('/').slice(0, -1).join('/'));
const r = await new Promise((resolve, reject) => {
const cfg = getConfig();
const base = new URL(cfg.url);
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
const mod = base.protocol === 'https:' ? https : http;
const headers = {
'Authorization': authHeader(cfg),
'Content-Type': mimeType,
'Content-Length': buffer.length,
};
const req = mod.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: fullPath,
method: 'PUT',
headers,
rejectUnauthorized: false,
}, res => { res.resume(); resolve({ status: res.statusCode }); });
req.on('error', reject);
req.write(buffer);
req.end();
});
if (r.status !== 201 && r.status !== 204) {
throw new Error(`PUT ${davPath}${r.status}`);
}
}
/**
* Datei von WebDAV downloaden (gibt Buffer zurück)
*/
async function downloadFile(davPath) {
const r = await davRequest('GET', davPath);
if (r.status !== 200) throw new Error(`GET ${davPath}${r.status}`);
return Buffer.from(r.body, 'binary');
}
/**
* Datei/Ordner auf WebDAV löschen
*/
async function deleteFile(davPath) {
const r = await davRequest('DELETE', davPath);
if (r.status !== 204 && r.status !== 200 && r.status !== 404) {
throw new Error(`DELETE ${davPath}${r.status}`);
}
}
/**
* Verbindung testen
*/
async function testConnection() {
const cfg = getConfig();
if (!cfg.url || !cfg.user || !cfg.password) throw new Error('WebDAV nicht konfiguriert');
const r = await davRequest('PROPFIND', cfg.basePath || '/', {
headers: { 'Depth': '0', 'Content-Type': 'application/xml' },
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>',
});
if (r.status !== 207 && r.status !== 200) throw new Error(`Verbindung fehlgeschlagen: HTTP ${r.status}`);
return true;
}
module.exports = { getConfig, userPath, mkdirp, uploadFile, downloadFile, deleteFile, testConnection };