Files
Lichttisch/main.js
T
2026-09-24 11:47:48 +02:00

451 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
// 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/>';
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>
<d:orderby><d:order><d:prop>${orderProp}</d:prop><d:descending/></d:order></d:orderby>
<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,
});
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}).`);
}
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));
handle('timeline', (offset, order) => searchMedia(offset, order));
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());