Files
Lichttisch/main.js
T

451 lines
17 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 11:46:54 +02:00
// 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[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 = `
<d:getlastmodified/><d:getcontenttype/><d:resourcetype/><d:getcontentlength/><d:displayname/>
<oc:fileid/><nc:has-preview/><nc:metadata-photos-original_date_time/>`;
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 = `<?xml version="1.0" encoding="UTF-8"?><d:propfind ${NS}><d:prop>${propsXml}</d:prop></d:propfind>`;
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;
}
2026-09-24 11:47:48 +02:00
// order: 'taken' = Aufnahmedatum aus den Metadaten (Nextcloud 28+),
// 'mtime' = Änderungs-/Upload-Datum der Datei
async function searchMedia(offset, order = 'taken') {
const orderProp = order === 'taken' ? '<nc:metadata-photos-original_date_time/>' : '<d:getlastmodified/>';
2026-09-24 11:46:54 +02:00
const body = `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
<d:basicsearch>
<d:select><d:prop>${FILE_PROPS}</d:prop></d:select>
<d:from><d:scope><d:href>/files/${xmlEscape(conn.userId)}</d:href><d:depth>infinity</d:depth></d:scope></d:from>
<d:where>
<d:or>
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>image/%</d:literal></d:like>
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>video/%</d:literal></d:like>
</d:or>
</d:where>
2026-09-24 11:47:48 +02:00
<d:orderby><d:order><d:prop>${orderProp}</d:prop><d:descending/></d:order></d:orderby>
2026-09-24 11:46:54 +02:00
<d:limit><d:nresults>${PAGE_SIZE}</d:nresults><ns:firstresult>${Number(offset) || 0}</ns:firstresult></d:limit>
</d:basicsearch>
</d:searchrequest>`;
const res = await ncFetch(`${conn.server}/remote.php/dav/`, {
method: 'SEARCH',
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
body,
});
2026-09-24 11:47:48 +02:00
if (res.status !== 207) {
// Ältere Server kennen das Aufnahmedatum nicht und lehnen die Sortierung ab
if (order === 'taken') return { unsupported: true, items: [], done: false };
throw new Error(`Die Suche nach Fotos ist fehlgeschlagen (Status ${res.status}).`);
}
2026-09-24 11:46:54 +02:00
const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir);
return { items, done: items.length < PAGE_SIZE };
}
const ALBUM_PROPS = `<nc:last-photo/><nc:nbItems/><nc:location/><nc:dateRange/><d:displayname/><d:resourcetype/>`;
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));
2026-09-24 11:47:48 +02:00
handle('timeline', (offset, order) => searchMedia(offset, order));
2026-09-24 11:46:54 +02:00
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());