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 }); 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; module.exports = router;

View File

@@ -8,13 +8,17 @@ const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); 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 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, destination: UPLOAD_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`), 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({ const getUpload = () => multer({
storage, storage,
limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 }, 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 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 (?,?,?,?,?,?)') 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); .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 };

View File

@@ -2696,6 +2696,104 @@ function UploadShareAdminPanel({ toast }) {
} }
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
function WebDavSettings({ toast }) {
const [cfg, setCfg] = useState({ url:'', user:'', password:'', basePath:'/dickendock', enabled:false });
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [showPw, setShowPw] = useState(false);
useEffect(() => {
api('/admin/webdav').then(d => setCfg(d)).catch(() => {});
}, []);
async function save() {
setSaving(true);
try {
await api('/admin/webdav', { method:'PUT', body: cfg });
toast('WebDAV Einstellungen gespeichert ✓');
} catch(e) { toast('Fehler: ' + e.message); }
finally { setSaving(false); }
}
async function test() {
setTesting(true);
try {
await api('/admin/webdav/test', { body: cfg });
toast('✓ Verbindung erfolgreich!');
} catch(e) { toast('✗ ' + e.message); }
finally { setTesting(false); }
}
const field = (label, key, type='text', placeholder='') => (
<div style={{marginBottom:10}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>{label}</div>
<div style={{position:'relative'}}>
<input
type={type === 'password' ? (showPw ? 'text' : 'password') : type}
value={cfg[key] || ''}
onChange={e => setCfg(p => ({...p, [key]: e.target.value}))}
placeholder={placeholder}
style={{...S.inp, width:'100%', boxSizing:'border-box', paddingRight: type==='password' ? 36 : 12}}
/>
{type === 'password' && (
<button onClick={()=>setShowPw(v=>!v)}
style={{position:'absolute',right:8,top:'50%',transform:'translateY(-50%)',background:'none',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:13}}>
{showPw ? '🙈' : '👁'}
</button>
)}
</div>
</div>
);
return (
<div>
{/* Enable Toggle */}
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16,cursor:'pointer'}}
onClick={() => setCfg(p => ({...p, enabled: !p.enabled}))}>
<div style={{
width:36, height:20, borderRadius:10, position:'relative',
background: cfg.enabled ? '#4ade80' : 'rgba(255,255,255,0.1)',
border: `1px solid ${cfg.enabled ? '#4ade80' : 'rgba(255,255,255,0.2)'}`,
transition: 'all 0.2s',
}}>
<div style={{
position:'absolute', top:2, left: cfg.enabled ? 18 : 2,
width:14, height:14, borderRadius:'50%', background:'#fff',
transition: 'left 0.2s',
}}/>
</div>
<span style={{color:cfg.enabled?'#4ade80':'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12}}>
{cfg.enabled ? 'WebDAV aktiv Dateien werden auf NAS gespiegelt' : 'WebDAV deaktiviert'}
</span>
</div>
{field('WEBDAV URL', 'url', 'text', 'http://192.168.1.100:5005 oder https://nas.local:5006')}
{field('BENUTZERNAME', 'user', 'text', 'admin')}
{field('PASSWORT', 'password', 'password', '')}
{field('BASISPFAD AUF DER SYNOLOGY', 'basePath', 'text', '/dickendock')}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginBottom:16,lineHeight:1.6}}>
Dateien werden unter <span style={{color:'rgba(255,255,255,0.5)'}}>Basispfad/Username/Dateiname</span> gespeichert.<br/>
Beispiel: /dickendock/flo/dokumente/rechnung.pdf<br/>
Synology WebDAV aktivieren: Systemsteuerung Dateidienste WebDAV
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={test} disabled={testing || !cfg.url}
style={{...S.btn('#60a5fa'), opacity:(testing||!cfg.url)?0.5:1}}>
{testing ? '⏳ Teste...' : '🔌 Verbindung testen'}
</button>
<button onClick={save} disabled={saving}
style={{...S.btn('#4ade80'), opacity:saving?0.5:1}}>
{saving ? '⏳...' : '💾 Speichern'}
</button>
</div>
</div>
);
}
function AdminPanel({ toast, mobile, user, nav }) { function AdminPanel({ toast, mobile, user, nav }) {
const [section, setSection] = useState('profil'); const [section, setSection] = useState('profil');
useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]); useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]);
@@ -2848,6 +2946,9 @@ function AdminPanel({ toast, mobile, user, nav }) {
<Sec title="TMDB API (KINO-FEATURE)"> <Sec title="TMDB API (KINO-FEATURE)">
<TmdbSettings toast={toast}/> <TmdbSettings toast={toast}/>
</Sec> </Sec>
<Sec title="SYNOLOGY NAS / WEBDAV">
<WebDavSettings toast={toast}/>
</Sec>
</div> </div>
)} )}