// Lichttisch – Nextcloud-Fotobetrachter // Hauptprozess: spricht per WebDAV/OCS mit der Nextcloud und reicht Bilder // über das eigene Protokoll nc:// an die Oberfläche durch. Nichts wird auf // die Festplatte geschrieben außer der Anmeldung (falls gewünscht). const { app, BrowserWindow, ipcMain, protocol, shell, safeStorage, Menu } = require('electron'); const path = require('path'); const fs = require('fs'); const { XMLParser } = require('fast-xml-parser'); const UA = 'Lichttisch (Linux Fotobetrachter)'; const PAGE_SIZE = 200; protocol.registerSchemesAsPrivileged([ { scheme: 'nc', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }, ]); let win = null; let conn = null; // { server, loginName, userId, displayName, auth } let loginFlowAbort = null; // ---------------------------------------------------------------- Konfiguration const configFile = () => path.join(app.getPath('userData'), 'config.json'); function loadConfig() { try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch { return {}; } } function saveConfig(cfg) { fs.mkdirSync(path.dirname(configFile()), { recursive: true }); fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 }); } function encryptSecret(s) { if (safeStorage.isEncryptionAvailable()) { return { enc: true, v: safeStorage.encryptString(s).toString('base64') }; } return { enc: false, v: Buffer.from(s, 'utf8').toString('base64') }; } function decryptSecret(o) { if (!o) return null; try { const buf = Buffer.from(o.v, 'base64'); return o.enc ? safeStorage.decryptString(buf) : buf.toString('utf8'); } catch { return null; } } // ---------------------------------------------------------------- Hilfsfunktionen function normalizeServer(s) { s = String(s || '').trim(); if (!s) throw new Error('Bitte die Adresse deiner Nextcloud eingeben.'); if (!/^https?:\/\//i.test(s)) s = 'https://' + s; s = s.replace(/\/(index\.php|remote\.php|apps|login)(\/.*)?$/i, ''); return s.replace(/\/+$/, ''); } const origin = (server) => new URL(server).origin; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function encodePath(p) { return p.split('/').map((seg) => encodeURIComponent(seg)).join('/'); } function xmlEscape(s) { return String(s).replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c])); } async function ncFetch(url, opts = {}) { if (!conn) throw new Error('Nicht angemeldet.'); const headers = { Authorization: conn.auth, 'User-Agent': UA, 'OCS-APIRequest': 'true', ...(opts.headers || {}) }; let res; try { res = await fetch(url, { ...opts, headers }); } catch (e) { throw new Error('Server nicht erreichbar: ' + (e.cause?.code || e.message)); } if (res.status === 401) throw new Error('Die Anmeldung wurde abgelehnt. Bitte neu anmelden.'); return res; } // ---------------------------------------------------------------- WebDAV-Antworten lesen const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: true, parseTagValue: false, isArray: (name) => name === 'response' || name === 'propstat', }); function parseMultistatus(xml) { const doc = parser.parse(xml); const responses = doc?.multistatus?.response || []; return responses.map((r) => { const props = {}; for (const ps of r.propstat || []) { if (/\s200\s/.test(String(ps.status) + ' ')) Object.assign(props, ps.prop || {}); } return { href: String(r.href), props }; }); } function kindOf(mime, isDir) { if (isDir) return 'dir'; if (mime.startsWith('image/')) return 'image'; if (mime.startsWith('video/')) return 'video'; return 'other'; } function toItem({ href, props }) { const isDir = typeof props.resourcetype === 'object' && props.resourcetype !== null && 'collection' in props.resourcetype; const mime = String(props.getcontenttype || ''); const decoded = decodeURIComponent(href); const name = String(props.displayname || '') || decoded.replace(/\/+$/, '').split('/').pop(); const mtime = props.getlastmodified ? Date.parse(props.getlastmodified) : 0; const takenRaw = Number(props['metadata-photos-original_date_time'] || 0); return { href, name, isDir, mime, kind: kindOf(mime, isDir), size: Number(props.getcontentlength || 0), mtime, taken: takenRaw > 0 ? takenRaw * 1000 : 0, fileId: props.fileid ? String(props.fileid) : null, hasPreview: String(props['has-preview']) === 'true', }; } const FILE_PROPS = ` `; const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; async function propfind(url, propsXml, depth = '1') { const body = `${propsXml}`; const res = await ncFetch(url, { method: 'PROPFIND', headers: { Depth: depth, 'Content-Type': 'text/xml; charset=utf-8' }, body }); if (res.status === 404) { const e = new Error('Nicht gefunden.'); e.code = 404; throw e; } if (res.status !== 207) throw new Error(`Unerwartete Antwort vom Server (${res.status}).`); return parseMultistatus(await res.text()); } // ---------------------------------------------------------------- Anmeldung async function finishLogin(server, loginName, password, remember) { server = normalizeServer(server); const auth = 'Basic ' + Buffer.from(`${loginName}:${password}`, 'utf8').toString('base64'); let res; try { res = await fetch(server + '/ocs/v2.php/cloud/user?format=json', { headers: { Authorization: auth, 'OCS-APIRequest': 'true', Accept: 'application/json', 'User-Agent': UA }, }); } catch (e) { throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`); } if (res.status === 401) throw new Error('Benutzername oder Passwort stimmt nicht. Bei aktivierter Zwei-Faktor-Anmeldung brauchst du ein App-Passwort.'); if (!res.ok) throw new Error(`Der Server antwortet mit Status ${res.status}. Ist das wirklich eine Nextcloud-Adresse?`); let data; try { data = (await res.json()).ocs.data; } catch { throw new Error('Unter dieser Adresse läuft offenbar keine Nextcloud.'); } conn = { server, loginName, userId: data.id, displayName: data['display-name'] || data.displayname || data.id, auth, }; thumbCache.clear(); const cfg = loadConfig(); cfg.server = server; cfg.loginName = loginName; if (remember) cfg.secret = encryptSecret(password); else delete cfg.secret; saveConfig(cfg); return publicConn(); } function publicConn() { return conn && { server: conn.server, userId: conn.userId, displayName: conn.displayName }; } async function loginFlow(serverInput, remember) { const server = normalizeServer(serverInput); let res; try { res = await fetch(server + '/index.php/login/v2', { method: 'POST', headers: { 'User-Agent': UA } }); } catch (e) { throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`); } if (!res.ok) throw new Error(`Die Browser-Anmeldung wird von diesem Server nicht angeboten (Status ${res.status}).`); const flow = await res.json(); await shell.openExternal(flow.login); const ctrl = { aborted: false }; loginFlowAbort = ctrl; const deadline = Date.now() + 20 * 60 * 1000; while (Date.now() < deadline) { await sleep(2000); if (ctrl.aborted) throw new Error('Anmeldung abgebrochen.'); let poll; try { poll = await fetch(flow.poll.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA }, body: 'token=' + encodeURIComponent(flow.poll.token), }); } catch { continue; } if (poll.status === 200) { const d = await poll.json(); loginFlowAbort = null; return finishLogin(d.server || server, d.loginName, d.appPassword, remember); } } throw new Error('Die Anmeldung im Browser hat zu lange gedauert. Bitte noch einmal versuchen.'); } // ---------------------------------------------------------------- Datenquellen async function listFolder(relPath) { const base = `${conn.server}/remote.php/dav/files/${encodeURIComponent(conn.userId)}`; const rel = ('/' + (relPath || '').replace(/^\/+|\/+$/g, '')).replace(/\/$/, '') + '/'; const entries = await propfind(base + encodePath(rel), FILE_PROPS, '1'); const marker = `/remote.php/dav/files/${conn.userId}`; const items = entries.slice(1).map(toItem).map((it) => { if (it.isDir) { const dec = decodeURIComponent(it.href); const i = dec.indexOf(marker); it.path = i >= 0 ? dec.slice(i + marker.length).replace(/\/+$/, '') : rel + it.name; } return it; }); return items; } async function searchMedia(offset) { const body = ` ${FILE_PROPS} /files/${xmlEscape(conn.userId)}infinity image/% video/% ${PAGE_SIZE}${Number(offset) || 0} `; const res = await ncFetch(`${conn.server}/remote.php/dav/`, { method: 'SEARCH', headers: { 'Content-Type': 'text/xml; charset=utf-8' }, body, }); if (res.status !== 207) throw new Error(`Die Suche nach Fotos ist fehlgeschlagen (Status ${res.status}).`); const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir); return { items, done: items.length < PAGE_SIZE }; } const ALBUM_PROPS = ``; async function listAlbums() { const u = encodeURIComponent(conn.userId); const out = []; let available = false; for (const [kind, shared] of [['albums', false], ['sharedalbums', true]]) { try { const entries = await propfind(`${conn.server}/remote.php/dav/photos/${u}/${kind}/`, ALBUM_PROPS, '1'); available = true; for (const e of entries.slice(1)) { const name = decodeURIComponent(e.href).replace(/\/+$/, '').split('/').pop(); let range = null; try { range = e.props.dateRange ? JSON.parse(e.props.dateRange) : null; } catch {} out.push({ href: e.href, name: shared ? name.replace(/\s*\([^)]*\)$/, '') : name, shared, count: Number(e.props.nbItems || 0), cover: e.props['last-photo'] && e.props['last-photo'] !== '-1' ? String(e.props['last-photo']) : null, location: e.props.location ? String(e.props.location) : '', start: range?.start ? range.start * 1000 : 0, end: range?.end ? range.end * 1000 : 0, }); } } catch (e) { if (e.code !== 404) throw e; } } return { available, albums: out }; } async function listAlbum(href) { if (!String(href).startsWith('/')) throw new Error('Ungültiges Album.'); const entries = await propfind(origin(conn.server) + href, FILE_PROPS, '1'); return entries.slice(1).map(toItem).filter((i) => !i.isDir); } // ---------------------------------------------------------------- Bild-Proxy nc:// // Vorschaubilder landen nur im Arbeitsspeicher (max. ca. 150 MB). const thumbCache = new Map(); let thumbBytes = 0; const THUMB_LIMIT = 150 * 1024 * 1024; function cachePut(key, entry) { if (thumbCache.has(key)) return; thumbCache.set(key, entry); thumbBytes += entry.buf.byteLength; while (thumbBytes > THUMB_LIMIT && thumbCache.size) { const [k, v] = thumbCache.entries().next().value; thumbCache.delete(k); thumbBytes -= v.buf.byteLength; } } async function handleNc(request) { if (!conn) return new Response('Nicht angemeldet', { status: 401 }); const u = new URL(request.url); try { if (u.hostname === 'preview') { const id = u.searchParams.get('id'); const s = Math.min(4096, Math.max(32, Number(u.searchParams.get('s')) || 256)); const key = `${id}:${s}`; const hit = thumbCache.get(key); if (hit) { thumbCache.delete(key); thumbCache.set(key, hit); // LRU auffrischen return new Response(hit.buf, { headers: { 'Content-Type': hit.type } }); } const res = await ncFetch(`${conn.server}/index.php/core/preview?fileId=${encodeURIComponent(id)}&x=${s}&y=${s}&a=1&forceIcon=0`); if (!res.ok) return new Response('', { status: res.status }); const buf = Buffer.from(await res.arrayBuffer()); const type = res.headers.get('content-type') || 'image/jpeg'; if (s <= 1024) cachePut(key, { buf, type }); return new Response(buf, { headers: { 'Content-Type': type } }); } if (u.hostname === 'file') { const href = u.searchParams.get('h') || ''; if (!href.startsWith('/')) return new Response('', { status: 400 }); const headers = {}; const range = request.headers.get('range'); if (range) headers.Range = range; const res = await ncFetch(origin(conn.server) + href, { headers }); const out = new Headers(); for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'last-modified']) { const v = res.headers.get(h); if (v) out.set(h, v); } return new Response(res.body, { status: res.status, headers: out }); } } catch (e) { return new Response(String(e.message), { status: 502 }); } return new Response('', { status: 404 }); } // ---------------------------------------------------------------- IPC function handle(channel, fn) { ipcMain.handle(channel, async (_e, ...args) => { try { return { ok: true, data: await fn(...args) }; } catch (e) { return { ok: false, error: e.message || String(e) }; } }); } handle('config', () => { const c = loadConfig(); return { server: c.server || '', loginName: c.loginName || '', hasSecret: !!c.secret }; }); handle('restore', async () => { const c = loadConfig(); const pw = decryptSecret(c.secret); if (!c.server || !c.loginName || !pw) return null; return finishLogin(c.server, c.loginName, pw, true); }); handle('login-password', (server, user, pass, remember) => finishLogin(server, user, pass, remember)); handle('login-browser', (server, remember) => loginFlow(server, remember)); handle('login-cancel', () => { if (loginFlowAbort) loginFlowAbort.aborted = true; return true; }); handle('logout', () => { conn = null; thumbCache.clear(); thumbBytes = 0; const c = loadConfig(); delete c.secret; saveConfig(c); return true; }); handle('folder', (p) => listFolder(p)); handle('timeline', (offset) => searchMedia(offset)); handle('albums', () => listAlbums()); handle('album', (href) => listAlbum(href)); handle('open-in-nextcloud', (fileId) => { if (!conn || !/^\d+$/.test(String(fileId))) return false; shell.openExternal(`${conn.server}/index.php/f/${fileId}`); return true; }); handle('toggle-fullscreen', () => { if (win) win.setFullScreen(!win.isFullScreen()); return win?.isFullScreen(); }); // ---------------------------------------------------------------- Fenster function createWindow() { win = new BrowserWindow({ width: 1280, height: 820, minWidth: 640, minHeight: 420, backgroundColor: '#1f2022', title: 'Lichttisch', autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); win.webContents.on('will-navigate', (e) => e.preventDefault()); win.loadFile(path.join(__dirname, 'renderer', 'index.html')); } app.whenReady().then(() => { Menu.setApplicationMenu(null); protocol.handle('nc', handleNc); createWindow(); }); app.on('window-all-closed', () => app.quit());