Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
642 lines (614 loc) · 40.9 KB
/
Copy pathserver.js
File metadata and controls
642 lines (614 loc) · 40.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
// Funscript Player – small web server without external dependencies.
// Serves the web interface, lists the library and streams videos (with range support).
// Die Teile stehen in lib/: config, util, library, settings, data, stash, heresphere, hq, update, selfcheck.
const http = require('http');
const https = require('https');
const net = require('net');
const fs = require('fs');
const crypto = require('crypto');
const { AUTH_IGNORED, STASH_IGNORED, PLACEHOLDERS, AUTH_USER, LOGIN_EXAMPLE, LOGIN_OFF, HOST, MEDIA_DIR, MIME, PORT, PUBLIC_DIR, VERSION, VIDEO_EXT } = require('./lib/config');
const path = require('path');
const { failedStats, loginBlocked, loginFailed, setGuardLogger } = require('./lib/guard');
const { readBody, safeJoin, sendFile, logSafe } = require('./lib/util');
const { libraryItems, libraryRescan, libraryScanMs } = require('./lib/library');
const { castToken, castValid, hsRevokeAll, loginCheck, sessionCount, sessionCreate, sessionDrop, sessionValid, sessionsClear, heresphereOn, hsAuthorized, hsRedirectOn, hsCheckPassword, hsToken, intifaceLanOnly, loginCheckHeader, loginOwnActive, publicSettings, serverSettings, setLoginPassword, writeSettings,
LOGIN_RESET, LOGIN_RESET_DONE, loginMissing, loginSource, loginUser, removeLogin, setupLogin, setStashConf, stashConf } = require('./lib/settings');
const { prefsEncryptStored, deleteUserScript, handyCacheGet, handyCachePut, ownScriptFile, ownScripts, readPrefs, readStore, saveUserScript, scriptHeat, userScriptFile, writePrefs, writeStore } = require('./lib/data');
const { proxyStash, stashFilters, stashSavedFilters, stashScene, stashSceneAction, stashScenes, stashSetFavorite, stashSetRating, stashCheck, stashCurrent, stashMonitor, stashReconfigure } = require('./lib/stash');
const { HS_HEADERS, heresphereIndex, heresphereMedia, heresphereStash, isVrVideo } = require('./lib/heresphere');
const { GPU_TEXT, hqBench, hqKillAll, hqProbe, hqStatus, hqStream } = require('./lib/hq');
const { UPDATE_OFF, updateInfo } = require('./lib/update');
const { runSelfcheck } = require('./lib/selfcheck');
const { PREFIX: STASH_UI_PREFIX, proxyStashUi, stashUiOn, upgradeStashUi } = require('./lib/stashui');
const sha = (s) => crypto.createHash('sha256').update(s).digest();
// test14: session cookie from the login page
const COOKIE = 'fsp_session';
function cookieToken(req) {
const m = new RegExp('(?:^|;\\s*)' + COOKIE + '=([0-9a-f]{64})').exec(req.headers.cookie || '');
return m ? m[1] : '';
}
const isHttps = (req) => !!req.socket?.encrypted || /^https$/i.test(String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim());
function sessionCookie(req, token, maxAge) {
return [`${COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', ...(maxAge ? [`Max-Age=${Math.round(maxAge)}`] : []),
...(token ? [] : ['Max-Age=0']), ...(isHttps(req) ? ['Secure'] : [])].join('; ');
}
async function startSession(req, res, remember) {
const { token, maxAge } = await sessionCreate(remember);
res.setHeader('Set-Cookie', sessionCookie(req, token, maxAge));
}
function authOk(req) {
const user0 = loginUser();
if (!user0) return true;
if (sessionValid(cookieToken(req))) return true;
// Basic auth stays as a fallback for scripts (curl …) – the browser no longer shows its dialog
const m = /^Basic (.+)$/.exec(req.headers.authorization || '');
if (!m) return false;
const [user, ...rest] = Buffer.from(m[1], 'base64').toString().split(':');
const okUser = crypto.timingSafeEqual(sha(user), sha(user0));
// test10/11: password from ⚙️ (lib/settings.js) – otherwise AUTH_PASS from the .env
const okPass = loginCheckHeader(m[1], rest.join(':'));
return okUser && okPass;
}
function checkAuth(req, res) {
if (!loginUser()) return true;
// 2.0: after too many failed attempts a one-minute pause (also for the right password)
if (req.headers.authorization && loginBlocked(req)) {
res.writeHead(429, { 'Retry-After': '60', 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Zu viele Fehlversuche – bitte eine Minute warten');
return false;
}
if (authOk(req)) return true;
if (req.headers.authorization) loginFailed(req, 'player');
// test14: pages go to the login page, everything else gets a plain 401 (no browser dialog)
const wantsPage = (req.method === 'GET' || req.method === 'HEAD') && /text\/html/.test(req.headers.accept || '');
if (wantsPage) {
res.writeHead(303, { Location: '/login?next=' + encodeURIComponent(req.url), 'Cache-Control': 'no-store' });
res.end();
return false;
}
res.writeHead(401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end('{"error":"login"}');
return false;
}
async function loginPassRoute(req, res, reset) {
if (loginBlocked(req)) return json(res, { error: 'blocked' }, 'no-store', 429);
const body = JSON.parse(await readBody(req, 2000) || '{}');
const r = await setLoginPassword(body.current, reset ? null : body.next);
if (r.msg === 'wrongCurrent') loginFailed(req, 'player');
// test14: a new password ends all sessions – this device stays logged in with a fresh one
if (r.status === 200) await startSession(req, res, true);
json(res, r.status === 200 ? { ok: true, own: loginOwnActive() } : { error: r.msg }, 'no-store', r.status);
}
// test11: set up a login in ⚙️ (only without a login) or remove it again (only the one set up in ⚙️)
async function loginSetupRoute(req, res, remove) {
if (loginBlocked(req)) return json(res, { error: 'blocked' }, 'no-store', 429);
const body = JSON.parse(await readBody(req, 2000) || '{}');
const r = remove ? await removeLogin(body.current) : await setupLogin(body.user, body.pass);
if (r.msg === 'wrongCurrent') loginFailed(req, 'player');
if (r.status === 200 && !remove) await startSession(req, res, true);
if (r.status === 200) logLine(remove ? 'Login removed in the interface' : `Login set up in the interface (user “${loginUser()}”)`);
json(res, r.status === 200 ? { ok: true } : { error: r.msg }, 'no-store', r.status);
}
// test11: Stash address/API key from ⚙️ – then check again and restart the monitoring
async function stashConfRoute(req, res, reset) {
const body = reset ? {} : JSON.parse(await readBody(req, 5000) || '{}');
const r = await setStashConf(body, reset);
if (r.status !== 200) return json(res, { error: r.msg }, 'no-store', r.status);
stashReconfigure();
const st = stashConf().url ? await stashCheck() : { configured: false };
logLine(`Stash: connection changed in the interface → ${stashConf().url || 'none'} (${st.ok ? 'connected' : st.configured ? st.error : 'not configured'})`);
startStashMonitor();
json(res, { settings: publicSettings(), status: st });
}
// Optional direct HTTPS: either TLS_PFX (+ TLS_PFX_PASS) or TLS_CERT + TLS_KEY (PEM).
function tlsOptions() {
if (process.env.TLS_PFX) return { pfx: fs.readFileSync(process.env.TLS_PFX), passphrase: process.env.TLS_PFX_PASS || '' };
if (process.env.TLS_CERT && process.env.TLS_KEY) return { cert: fs.readFileSync(process.env.TLS_CERT), key: fs.readFileSync(process.env.TLS_KEY) };
return null;
}
const tls = tlsOptions();
// Don't embed in other pages, don't guess file types, don't pass the address on to other sites
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
// 1.19.5: Content Security Policy – only own scripts (none injected), connections only to the own server,
// to the Handy cloud and to Intiface (ws:// in the home network when the player runs without HTTPS)
'Content-Security-Policy': [
"default-src 'self'", "script-src 'self' https://www.gstatic.com", "style-src 'self'", "img-src 'self' data: blob:", "media-src 'self' blob:",
"connect-src 'self' https://www.handyfeeling.com ws: wss:", "object-src 'none'", "base-uri 'self'", "form-action 'self'",
"frame-ancestors 'self'",
].join('; '),
'Referrer-Policy': 'same-origin',
};
// Does the request come from this page? Without an Origin (e.g. curl, apps) it counts as our own.
function sameOrigin(req) {
const site = req.headers['sec-fetch-site'];
if (site && site !== 'same-origin' && site !== 'none') return false;
const origin = req.headers.origin;
if (!origin) return true;
try {
const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
return new URL(origin).host === host;
} catch { return false; }
}
// 2.0: page while no login is set up and LOGIN=off is not set.
// test11: setup page – set user + password here (like Portainer) or as before via the .env.
// Without JavaScript (plain form), look from /setup.css (the security rules allow no inline CSS).
const SETUP_CSS = `body{font:16px/1.5 system-ui,sans-serif;max-width:34em;margin:2em auto;padding:0 1em;background:#0b0b0d;color:#eee}
h1{font-size:1.4em}label{display:block;margin:.8em 0}input{display:block;width:100%;box-sizing:border-box;margin-top:.3em;padding:.7em;
font:inherit;background:#18181c;color:#eee;border:1px solid #333;border-radius:8px}button{margin-top:1em;padding:.8em 1.4em;font:inherit;
font-weight:600;color:#fff;background:#7c3aed;border:0;border-radius:10px;cursor:pointer}.err{color:#ffb020}.note{color:#aaa;font-size:.9em}
code{background:#222;padding:0 .3em;border-radius:4px}hr{border:0;border-top:1px solid #333;margin:2em 0}
label.check{display:flex;gap:.6em;align-items:center}label.check input{width:auto;display:inline;margin:0;accent-color:#9b5fe0}`;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const SETUP_ERR = {
badUser: 'Please enter a user name (no colon) · Bitte einen Benutzernamen eingeben (ohne Doppelpunkt)',
tooShort: 'The password needs at least 8 characters · Das Passwort braucht mindestens 8 Zeichen',
example: 'Please choose your own password · Bitte ein eigenes Passwort wählen',
mismatch: 'The passwords don’t match · Die Passwörter stimmen nicht überein',
blocked: 'Too many attempts – wait a minute · Zu viele Versuche – eine Minute warten',
origin: 'Please send the form from this page · Bitte das Formular auf dieser Seite absenden',
};
const loginPage = (err = '', user = '') => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Funscript Player – set up a login</title><link rel="stylesheet" href="/setup.css">
<h1>Funscript Player: set up a login · Anmeldung einrichten</h1>
<p>The player needs a login – otherwise everyone who can reach it sees your library and Handy key.<br>
<span lang="de">Der Player braucht eine Anmeldung – sonst sieht jeder, der ihn erreicht, deine Bibliothek und den Handy-Key.</span></p>
${LOGIN_EXAMPLE ? '<p class="err">AUTH_PASS in the .env is still the example password <code>change-me</code> – it does not count. · Das Beispielpasswort <code>change-me</code> gilt nicht.</p>' : ''}
${err ? `<p class="err">${esc(SETUP_ERR[err] || err)}</p>` : ''}
<form method="post" action="/setup-login">
<label>User name · Benutzername<input name="user" autocomplete="username" required maxlength="64" value="${esc(user)}"></label>
<label>Password (at least 8 characters) · Passwort (mind. 8 Zeichen)<input name="pass" type="password" autocomplete="new-password" required minlength="8"></label>
<label>Repeat password · Passwort wiederholen<input name="pass2" type="password" autocomplete="new-password" required minlength="8"></label>
<button type="submit">Set up login · Anmeldung einrichten</button>
</form>
<p class="note">You are logged in right away (this device stays logged in). Change or remove it later in ⚙️ → Security & privacy.
Forgotten? Add <code>LOGIN_RESET=1</code> to the .env, run <code>docker compose up -d</code>, then remove the line again.<br>
<span lang="de">Du bist danach gleich angemeldet (dieses Gerät bleibt angemeldet). Ändern oder entfernen später in ⚙️ → Sicherheit & Privatsphäre.
Vergessen? <code>LOGIN_RESET=1</code> in die .env, <code>docker compose up -d</code>, Zeile danach wieder entfernen.</span></p>
<hr><p class="note">Alternatively in the <code>.env</code>: <code>AUTH_USER=</code> + <code>AUTH_PASS=</code> (takes precedence), or <code>LOGIN=off</code>
if the player is protected otherwise (e.g. a reverse proxy with its own login). Then <code>docker compose up -d</code>.<br>
<span lang="de">Alternativ in der <code>.env</code>: <code>AUTH_USER</code>/<code>AUTH_PASS</code> (gehen vor) oder <code>LOGIN=off</code>, wenn er anders
geschützt ist (z. B. Reverse Proxy mit eigener Anmeldung).</span></p>`;
// test14: login page (no JavaScript, same look as the setup page) – user + password, „stay logged in“
const LOGIN_ERR = {
wrong: 'User name or password is wrong · Benutzername oder Passwort falsch',
blocked: 'Too many attempts – wait a minute · Zu viele Versuche – eine Minute warten',
origin: 'Please send the form from this page · Bitte das Formular auf dieser Seite absenden',
};
// Only paths of this player as target after the login (no //other.host)
const safeNext = (n) => (/^\/(?!\/)[^\s\\]*$/.test(String(n || '')) && !/^\/(login|logout)\b/.test(n) ? n : '/');
const loginFormPage = (next, err = '', user = '') => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Funscript Player – log in</title><link rel="stylesheet" href="/setup.css"><link rel="manifest" href="/manifest.webmanifest">
<h1>Funscript Player</h1>
${err ? `<p class="err">${esc(LOGIN_ERR[err] || err)}</p>` : ''}
<form method="post" action="/login">
<input type="hidden" name="next" value="${esc(safeNext(next))}">
<label>User name · Benutzername<input name="user" autocomplete="username" required maxlength="64" value="${esc(user)}" autofocus></label>
<label>Password · Passwort<input name="pass" type="password" autocomplete="current-password" required></label>
<label class="check"><input type="checkbox" name="remember" checked> Stay logged in (30 days) · Angemeldet bleiben (30 Tage)</label>
<button type="submit">Log in · Anmelden</button>
</form>
<p class="note">Forgotten? <code>LOGIN_RESET=1</code> in the .env removes a login set up in the interface; with <code>AUTH_USER</code>/<code>AUTH_PASS</code>
the .env values apply. · Vergessen? Siehe README → Security.</p>`;
async function loginRoute(req, res) {
const page = (status, next, err, user) => {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...SECURITY_HEADERS });
res.end(loginFormPage(next, err, user));
};
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'POST') {
// Already logged in (or no login at all) → straight on
if (authOk(req)) { res.writeHead(303, { Location: safeNext(url.searchParams.get('next')), 'Cache-Control': 'no-store' }); return res.end(); }
return page(200, url.searchParams.get('next'));
}
const f = new URLSearchParams(await readBody(req, 4000));
const next = f.get('next');
if (!sameOrigin(req)) return page(403, next, 'origin');
if (loginBlocked(req)) return page(429, next, 'blocked', f.get('user') || '');
if (!loginCheck(f.get('user'), f.get('pass'))) {
loginFailed(req, 'player');
return page(401, next, 'wrong', f.get('user') || '');
}
await startSession(req, res, f.get('remember') === 'on');
res.writeHead(303, { Location: safeNext(next), 'Cache-Control': 'no-store' });
res.end();
}
async function logoutRoute(req, res) {
if (req.method !== 'POST' || !sameOrigin(req)) { res.writeHead(405); return res.end(); }
await sessionDrop(cookieToken(req));
res.setHeader('Set-Cookie', sessionCookie(req, '', 0));
res.writeHead(303, { Location: '/login', 'Cache-Control': 'no-store' });
res.end();
}
// Without login: only these files (look of the login page, app icon and manifest for „install as app“)
const PUBLIC_FILES = new Set(['/manifest.webmanifest', '/icon.svg', '/icon-180.png', '/icon-192.png', '/icon-512.png']);
async function loginSetupPage(req, res) {
const path0 = req.url.split('?')[0];
if (path0 === '/setup.css') {
res.writeHead(200, { 'Content-Type': MIME['.css'], 'Cache-Control': 'no-store' });
return res.end(SETUP_CSS);
}
const page = (status, err, user) => {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...SECURITY_HEADERS });
res.end(loginPage(err, user));
};
if (path0 !== '/setup-login' || req.method !== 'POST') return page(503);
if (!sameOrigin(req)) return page(403, 'origin');
if (loginBlocked(req)) return page(429, 'blocked');
const f = new URLSearchParams(await readBody(req, 4000));
if (f.get('pass') !== f.get('pass2')) return page(400, 'mismatch', f.get('user') || '');
const r = await setupLogin(f.get('user'), f.get('pass'));
if (r.status !== 200) { loginFailed(req, 'setup'); return page(r.status === 409 ? 503 : 400, r.msg, f.get('user') || ''); }
logLine(`Login set up on the setup page (user “${loginUser()}”)`);
await startSession(req, res, true); // test14: logged in right away
res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
res.end();
}
// Answers and small helpers for the routes
const json = (res, data, cache = 'no-store', status = 200) => {
res.writeHead(status, { 'Content-Type': MIME['.json'], 'Cache-Control': cache });
res.end(JSON.stringify(data));
};
const noContent = (res) => { res.writeHead(204); res.end(); };
const needStash = () => { if (!stashConf().url) throw Object.assign(new Error('Stash ist nicht eingerichtet'), { status: 404 }); };
const userSrc = (url) => {
const src = url.searchParams.get('src') || '';
if (!/^(stash|media):/.test(src)) throw Object.assign(new Error('Ungültige Quelle'), { status: 400 });
return src;
};
// Error reports from the browser (quiet self-test, JavaScript errors) – at most 20 per minute in „docker logs“
let clientLogs = { at: 0, n: 0 };
async function clientReport(req, res) {
const r = JSON.parse((await readBody(req, 20000)) || '{}');
const fails = Array.isArray(r.fails) ? r.fails.slice(0, 20).map((f) => String(f).replace(/\s+/g, ' ').slice(0, 200)) : [];
if (Date.now() - clientLogs.at > 60000) clientLogs = { at: Date.now(), n: 0 };
if (fails.length && clientLogs.n++ < 20) logLine(`Interface (${String(r.ua || '').replace(/\s+/g, ' ').slice(0, 60)}): ✗ ${fails.join(' · ')}`);
noContent(res);
}
// Feste Pfade: { pfad: { METHODE: (req, res, url) => … } }
const ROUTES = {
'/api/store': {
GET: async (req, res) => json(res, await readStore()),
PUT: async (req, res) => { await writeStore(req); noContent(res); },
},
'/api/prefs': {
GET: async (req, res) => json(res, await readPrefs()),
PUT: async (req, res) => { await writePrefs(req); json(res, await readPrefs()); },
},
'/api/userscript': {
POST: async (req, res) => json(res, await saveUserScript(req)),
// ?name=… = named own script (2.0), without = the one own script of the scene
GET: async (req, res, url) => {
const src = userSrc(url), name = url.searchParams.get('name');
if (name === null) return sendFile(req, res, (await ownScriptFile(src)) || userScriptFile(src));
const file = await ownScriptFile(src, name);
if (!file) throw Object.assign(new Error('Kein eigenes Script'), { status: 404 });
return sendFile(req, res, file);
},
DELETE: async (req, res, url) => { const name = url.searchParams.get('name'); await deleteUserScript(userSrc(url), name ?? undefined); noContent(res); },
},
// Which videos have an own script (for ✋ in the library and the script choice)
// test14: security overview (failed logins, sessions) and „log out everywhere“ (player / HereSphere)
'/api/security': { GET: async (req, res) => json(res, { failed: failedStats(), sessions: sessionCount() }, 'no-store') },
'/api/logout-all': { POST: async (req, res) => { await sessionsClear(); logLine('All devices logged out (player)'); res.setHeader('Set-Cookie', sessionCookie(req, '', 0)); json(res, { ok: true }, 'no-store'); } },
'/api/heresphere/revoke': { POST: async (req, res) => { await hsRevokeAll(); logLine('All HereSphere devices logged out'); json(res, { ok: true }, 'no-store'); } },
'/api/userscripts': { GET: async (req, res) => json(res, await ownScripts()) },
// test13: cast token for „stream to TV“
'/api/cast/token': { GET: async (req, res) => json(res, castToken()) },
'/api/heat': { GET: async (req, res, url) => json(res, await scriptHeat(url.searchParams.get('src')), 'max-age=3600') },
// test11: set up a login in ⚙️ (POST, only without a login) / remove it (DELETE, only the one set up in ⚙️)
'/api/login/setup': {
POST: async (req, res) => loginSetupRoute(req, res, false),
DELETE: async (req, res) => loginSetupRoute(req, res, true),
},
// test11: Stash address + API key from ⚙️ (PUT), DELETE = back to the values from the .env
'/api/stash/config': {
PUT: async (req, res) => stashConfRoute(req, res, false),
DELETE: async (req, res) => stashConfRoute(req, res, true),
},
// test10: change the player password (login from .env or ⚙️); DELETE = back to the password from the .env
'/api/login/password': {
POST: async (req, res) => loginPassRoute(req, res, false),
DELETE: async (req, res) => loginPassRoute(req, res, true),
},
'/api/settings': {
GET: async (req, res) => json(res, publicSettings()),
PUT: async (req, res) => { await writeSettings(req); json(res, publicSettings()); },
},
'/api/handycache': {
GET: async (req, res, url) => { const hit = await handyCacheGet(url.searchParams.get('k') || ''); json(res, hit || {}, 'no-store', hit ? 200 : 404); },
PUT: async (req, res) => { await handyCachePut(req); noContent(res); },
},
'/api/clientcheck': { POST: clientReport },
'/api/update': { GET: async (req, res) => json(res, await updateInfo()) },
'/api/library': {
GET: async (req, res) => json(res, (await libraryItems()).map((it) => ({ ...it, vr: isVrVideo(it.video) }))
.sort((a, b) => a.video.localeCompare(b.video, 'de', { numeric: true }))),
},
// test10: cached state (checks again by itself in the background); ?fresh=1 = „test connection“
'/api/stash/status': { GET: async (req, res) => json(res, /[?&]fresh=1/.test(req.url) ? await stashCheck() : await stashCurrent()) },
'/api/stash/saved-filters': { GET: async (req, res) => { needStash(); json(res, (await stashSavedFilters()).map(({ id, name }) => ({ id, name }))); } },
'/api/stash/filters': { GET: async (req, res) => { needStash(); json(res, await stashFilters()); } },
'/api/stash/scenes': { GET: async (req, res, url) => { needStash(); json(res, await stashScenes(url.searchParams)); } },
'/hq/stream': { GET: (req, res, url) => hqStream(req, res, url.searchParams) },
'/api/hq/status': { GET: async (req, res) => json(res, await hqStatus()) },
'/api/hq/probe': { GET: async (req, res, url) => json(res, await hqProbe(url.searchParams.get('src'))) },
'/api/hq/bench': { GET: async (req, res, url) => json(res, await hqBench(url.searchParams)) },
};
// test13: only these addresses work with a cast token (video, still image, HQ stream) – no settings, no API
const CAST_PATH = /^\/(media\/[^?]+|stash\/scene\/\d+\/(stream|stream\.mp4|screenshot)|hq\/stream)$/;
// LOG_LEVEL=debug: every request; otherwise only slow (> 1 s) API requests – not videos/streams
const LOG_DEBUG = /^debug$/i.test(process.env.LOG_LEVEL || '');
const handler = async (req, res) => {
const t0 = Date.now();
res.on('finish', () => {
const ms = Date.now() - t0;
if (LOG_DEBUG || (ms > 1000 && /^\/(api|heresphere)/.test(req.url))) {
logLine(`${ms > 1000 ? 'slow' : 'request'}: ${req.method} ${req.url.split('?')[0]} → ${res.statusCode} in ${ms} ms`);
}
});
try {
if (req.url === '/healthz') return res.end('ok'); // for the Docker health check – without login, reveals nothing
// 2.0: no login set up and not deliberately switched off → only the setup page, nothing else
if (loginMissing()) return await loginSetupPage(req, res);
// test14: login page, logout, its stylesheet and the app icons work without a session
const p0 = req.url.split('?')[0];
if (p0 === '/login') return await loginRoute(req, res);
if (p0 === '/logout') return await logoutRoute(req, res);
if (p0 === '/setup.css') {
res.writeHead(200, { 'Content-Type': MIME['.css'], 'Cache-Control': 'no-store' });
return res.end(SETUP_CSS);
}
if (PUBLIC_FILES.has(p0) && (req.method === 'GET' || req.method === 'HEAD')) return sendFile(req, res, path.join(PUBLIC_DIR, p0.slice(1)));
// 2.0: HereSphere with the normal address → straight to the VR library. Before the login, because HereSphere can't
// do a browser login; only exactly „/“ is redirected, everything else stays behind the login.
if (req.url.split('?')[0] === '/' && (req.method === 'GET' || req.method === 'HEAD') && /HereSphere/i.test(req.headers['user-agent'] || '') && hsRedirectOn()) {
res.writeHead(302, { Location: '/heresphere', 'Cache-Control': 'no-store' });
return res.end();
}
// 2.0: HereSphere can't do a browser login. With its own HereSphere password the player checks /heresphere itself
// (token per request) – then the login doesn't apply there. Without a password /heresphere stays behind the login too.
const hsOwnLogin = /^\/heresphere(\/|$)/.test(req.url.split('?')[0]) && heresphereOn() && !!serverSettings.hsPass;
// test13: the TV (Chromecast/AirPlay) only fetches the video – with a valid cast token without the browser login
const castOk = (req.method === 'GET' || req.method === 'HEAD') && CAST_PATH.test(req.url.split('?')[0])
&& castValid(new URL(req.url, 'http://localhost').searchParams.get('cast'));
if (!hsOwnLogin && !castOk && !checkAuth(req, res)) return;
// 1.19.10: the version number only after the login (CodeQL #17)
if (req.url === '/api/version') {
res.writeHead(200, { 'Content-Type': MIME['.json'] });
return res.end(JSON.stringify({ version: VERSION }));
}
const url = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(url.pathname);
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
// Changes only from our own page – other websites in the same browser must not trigger anything.
// HereSphere (its own app, without Origin) has the optional password for that.
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) && !pathname.startsWith('/heresphere') && !sameOrigin(req)) {
throw Object.assign(new Error('Fremde Herkunft'), { status: 403 });
}
// Fixed paths from the table (ROUTES), GET also applies to HEAD
const route = ROUTES[pathname];
if (route) {
const fn = route[req.method === 'HEAD' ? 'GET' : req.method];
if (!fn) throw Object.assign(new Error('Methode nicht erlaubt'), { status: 405 });
return await fn(req, res, url);
}
const sa = /^\/api\/stash\/scene\/(\d+)(?:\/(activity|play|rating|favorite|o|o-undo|marker))?$/.exec(pathname);
if (sa) {
needStash();
if (sa[2] && req.method === 'POST') return json(res, (await stashSceneAction(sa[1], sa[2], req)) || { ok: true });
if (!sa[2] && req.method === 'GET') return json(res, await stashScene(sa[1]));
throw Object.assign(new Error('Methode nicht erlaubt'), { status: 405 });
}
// HereSphere asks via GET or POST. For Stash scenes the POST may contain a
// new rating (0–5) or the favourite state – that is written to Stash.
if (pathname === '/heresphere' || pathname.startsWith('/heresphere/')) {
// Switched off: no answer – the exception in the proxy login can go then
if (!heresphereOn()) throw Object.assign(new Error('HereSphere ist abgeschaltet'), { status: 404 });
// 2.0: videos, scripts and previews for HereSphere only here – every request checked with the token. So the
// proxy only needs the exception for /heresphere; /stash/… and /media/… always stay behind the login.
const hf = /^\/heresphere\/file\/(?:stash\/(\d+)\/(stream|funscript|screenshot|preview)|media\/(.+))$/.exec(pathname);
if (hf) {
if (req.method !== 'GET' && req.method !== 'HEAD') throw Object.assign(new Error('Methode nicht erlaubt'), { status: 405 });
if (!hsAuthorized(req, url)) throw Object.assign(new Error('Anmeldung erforderlich'), { status: 401 });
url.searchParams.delete('hs'); // don't pass the token on to Stash
if (hf[1]) {
needStash();
return await proxyStash(req, res, 'scene', hf[1], hf[2], url.search);
}
const ext = path.extname(hf[3]).toLowerCase();
const file = (VIDEO_EXT.has(ext) || ext === '.funscript') && safeJoin(MEDIA_DIR, hf[3]);
if (!file) throw Object.assign(new Error('Nicht erlaubt'), { status: 403 });
return sendFile(req, res, file);
}
let body = {};
try { body = JSON.parse((await readBody(req, 100000)) || '{}'); } catch { body = {}; }
let data;
if (pathname === '/heresphere/auth') {
// Login from HereSphere (user name doesn't matter) -> token that HereSphere sends with every request
// 2.0: brake – after too many failed attempts no login for a minute
const ok = !loginBlocked(req) && hsCheckPassword(String(body.password || ''));
if (!ok && serverSettings.hsPass) loginFailed(req, 'heresphere');
data = ok ? { 'auth-token': hsToken(), access: 1 } : { 'auth-token': '', access: -1 };
} else if (!hsAuthorized(req, url)) {
// -1 = login needed: HereSphere then asks for user and password
data = { access: -1, library: [] };
} else if (pathname === '/heresphere' || pathname === '/heresphere/') data = await heresphereIndex(req);
else if (/^\/heresphere\/stash\/\d+$/.test(pathname)) {
needStash();
const id = pathname.split('/').pop();
data = await heresphereStash(req, id); // checks „VR only“ first
if (typeof body.rating === 'number') {
await stashSetRating(id, body.rating * 20).catch((e) => logLine(`HereSphere rating not saved: ${e.message}`));
data.rating = body.rating;
}
if (typeof body.isFavorite === 'boolean') {
await stashSetFavorite(id, body.isFavorite).catch((e) => logLine(`HereSphere favourite not saved: ${e.message}`));
data.isFavorite = body.isFavorite;
}
} else if (pathname.startsWith('/heresphere/media/')) data = await heresphereMedia(req, pathname.slice('/heresphere/media/'.length), body.needsMediaSource !== false);
else throw Object.assign(new Error('Nicht gefunden'), { status: 404 });
res.writeHead(200, HS_HEADERS);
return res.end(JSON.stringify(data));
}
// Only these Stash files are passed through by the player itself: videos, Funscript, previews, seek sprites and images
const sm = /^\/stash\/(scene)\/(\d+)\/(stream|stream\.mp4|funscript|screenshot|preview|webp|vtt\/thumbs|vtt\/sprite)$/.exec(pathname)
|| /^\/stash\/(tag|performer|studio)\/(\d+)\/(image)$/.exec(pathname);
// test15: everything else under /stash is Stash's own interface (switchable in ⚙️ → Library)
if (!sm && (pathname === STASH_UI_PREFIX || pathname.startsWith(STASH_UI_PREFIX + '/'))) {
if (!stashUiOn()) throw Object.assign(new Error('Stash interface is off'), { status: 404 });
if (pathname === STASH_UI_PREFIX) {
res.writeHead(302, { Location: STASH_UI_PREFIX + '/', 'Cache-Control': 'no-store' });
return res.end();
}
return await proxyStashUi(req, res, req.url.slice(STASH_UI_PREFIX.length));
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405);
return res.end();
}
if (sm) {
needStash();
// test13: the cast token is not passed on to Stash
const sp = new URLSearchParams(url.search);
sp.delete('cast');
const search = sp.toString() ? '?' + sp : '';
return await proxyStash(req, res, sm[1], sm[2], sm[3], search);
}
// Files: /media/… from the video folders, otherwise the interface
const file = pathname.startsWith('/media/') ? safeJoin(MEDIA_DIR, pathname.slice('/media/'.length))
: safeJoin(PUBLIC_DIR, pathname === '/' || pathname === '/demo' ? 'index.html' : pathname.slice(1));
if (!file) {
res.writeHead(403);
return res.end();
}
return sendFile(req, res, file);
} catch (err) {
const status = err.status || (err instanceof SyntaxError ? 400 : 500);
if (status === 500) console.error(err);
// Stash/Handy cache unreachable (502/503/504): also in „docker logs“, otherwise you only see the interface
else if (status >= 502) logLine(`Error ${status}: ${req.method} ${req.url.split('?')[0]} – ${err.message}`);
if (res.headersSent) return res.destroy();
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(status === 500 ? 'Serverfehler' : err.message);
}
};
const server = tls ? https.createServer(tls, handler) : http.createServer(handler);
// WebSocket relay /intiface?target=IP:PORT -> ws://IP:PORT (Intiface Central on the phone).
// Needed because an HTTPS page must not open an unencrypted ws:// connection.
// Only private IPv4 addresses are allowed; with „home network only“ (default since 1.19.5, ⚙️ → Devices / INTIFACE_LAN_ONLY)
// only 192.168.x and 10.x – so no Docker networks (172.x) and nothing in the container itself (127.x).
const PRIVATE_IP = /^(10\.\d+|192\.168|172\.(1[6-9]|2\d|3[01])|127\.\d+)\.\d+\.\d+$/;
const LAN_IP = /^(10\.\d+|192\.168)\.\d+\.\d+$/;
// 1.19.5: only the Intiface port (default 12345, several separated by commas in INTIFACE_PORTS) – so the
// relay doesn't become a tunnel to arbitrary services in the network
const INTIFACE_PORTS = new Set((process.env.INTIFACE_PORTS || '12345').split(',').map((p) => Number(p.trim())).filter(Boolean));
server.on('upgrade', (req, socket, head) => {
const fail = (code, text) => socket.end(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\n\r\n`);
socket.on('error', () => {});
const url = new URL(req.url, 'http://localhost');
// test15: GraphQL subscriptions of Stash's interface (/stash/graphql)
if (url.pathname === STASH_UI_PREFIX + '/graphql') {
if (loginMissing() || !authOk(req)) return fail(401, 'Unauthorized');
if (!sameOrigin(req)) return fail(403, 'Forbidden');
if (!stashUiOn()) return fail(404, 'Not Found');
return upgradeStashUi(req, socket, head);
}
if (url.pathname !== '/intiface') return fail(404, 'Not Found');
if (loginMissing() || !authOk(req)) return fail(401, 'Unauthorized');
if (!sameOrigin(req)) return fail(403, 'Forbidden'); // no connection from other websites
const m = /^([\d.]+):(\d{1,5})$/.exec(url.searchParams.get('target') || '');
if (!m || !(intifaceLanOnly() ? LAN_IP : PRIVATE_IP).test(m[1]) || !INTIFACE_PORTS.has(Number(m[2]))) return fail(400, 'Bad Request');
const upstream = net.connect(Number(m[2]), m[1]);
upstream.setTimeout(5000, () => upstream.destroy(new Error('timeout')));
upstream.on('connect', () => {
upstream.setTimeout(0);
const lines = ['GET / HTTP/1.1', `Host: ${m[1]}:${m[2]}`];
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const name = req.rawHeaders[i];
if (!/^(host|authorization|cookie)$/i.test(name)) lines.push(`${name}: ${req.rawHeaders[i + 1]}`);
}
upstream.write(lines.join('\r\n') + '\r\n\r\n');
if (head.length) upstream.write(head);
upstream.pipe(socket);
socket.pipe(upstream);
});
upstream.on('error', (err) => {
if (!socket.destroyed && /** @type {import('net').Socket} */ (socket).bytesWritten === 0) fail(502, 'Bad Gateway');
else socket.destroy();
console.warn(logSafe(`Intiface ${m[1]}:${m[2]} not reachable: ${err.message}`));
});
socket.on('close', () => upstream.destroy());
});
// Start summary in „docker logs“ (English since 1.19.7): what was found, what is reachable – and when the player is ready
const logLine = (msg) => console.log(`[${new Date().toTimeString().slice(0, 8)}] ${logSafe(msg)}`);
setGuardLogger(logLine);
// 2.0 after test7: Docker stops with SIGTERM. Without handling, Node as process 1 ignores the signal and Docker
// waits 10 s for the hard kill. So: end ffmpeg, close connections, exit right away.
for (const sig of ['SIGTERM', 'SIGINT']) {
process.once(sig, () => {
logLine(`Stopping (${sig}) …`);
hqKillAll();
server.close(() => process.exit(0));
server.closeAllConnections?.();
setTimeout(() => process.exit(0), 1500).unref();
});
}
// Stash 0.32+ refuses foreign networks without a Stash login with 403 – explain what to do
function logStash403(st) {
logLine(' Stash 0.32+ rejects requests from networks it considers public when Stash has no login. Either:');
logLine(` a) in Stash's config.yml: public_whitelist: [${(st.networks || []).map((n) => '"' + n + '"').join(', ') || '"<docker network>/16"'}] – then restart Stash, or`);
logLine(' b) set a user + password in Stash (Settings → Security) and put its API key into ⚙️ → Library or STASH_API_KEY.');
}
// Check Stash again by itself (every 30 s / 5 min) and log changes – also after a change in ⚙️
function startStashMonitor() {
stashMonitor((now) => {
if (now.ok) logLine(`Stash: reachable again (version ${now.version || '?'})`);
else { logLine(`Stash: no longer reachable – ${now.error}`); if (now.code === 'forbidden') logStash403(now); }
});
}
server.listen(PORT, HOST, async () => {
logLine(`Funscript Player ${VERSION} – listening on ${tls ? 'https' : 'http'}://${HOST}:${PORT}`);
// test11: which login applies – .env > set up in ⚙️ > LOGIN=off > setup page
if (LOGIN_RESET_DONE) logLine('LOGIN_RESET=1: the login set in the interface was removed – now remove LOGIN_RESET from .env again.');
else if (LOGIN_RESET) logLine('Note: LOGIN_RESET=1 is set but there was nothing to reset – remove the line from .env.');
if (LOGIN_EXAMPLE) logLine('⚠ AUTH_PASS is still the example password "change-me" – it does not count.');
const src = loginSource();
if (src === 'env') logLine(`Login: from .env (user “${AUTH_USER}”${loginOwnActive() ? ', password changed in the interface' : ''})${serverSettings.uiLogin ? ' – the login set in the interface is ignored' : ''}`);
else if (src === 'ui') logLine(`Login: set up in the interface (user “${loginUser()}”)${LOGIN_OFF ? ' – takes precedence over LOGIN=off' : ''}`);
else if (src === 'off') logLine('Login: off (LOGIN=off) – everyone who reaches the player sees your library and Handy key. A login can be set up in ⚙️.');
else logLine('⚠ NO LOGIN SET UP: the player shows the setup page – set user + password there (or AUTH_USER + AUTH_PASS in .env, or LOGIN=off).');
if (AUTH_IGNORED) logLine('Note: LOGIN=off – AUTH_USER/AUTH_PASS in .env are ignored.');
if (STASH_IGNORED) logLine('Note: STASH=off – STASH_URL/STASH_API_KEY in .env are ignored.');
if (stashConf().source === 'ui') logLine('Stash: address/API key set in the interface (overrides STASH_URL/STASH_API_KEY from .env)');
if (PLACEHOLDERS.length) logLine(`Note: placeholder <…> in .env counts as empty: ${PLACEHOLDERS.join(', ')}`);
logLine(`Library: reading ${MEDIA_DIR} …`);
const STASH_URL = stashConf().url;
const [lib, stash, hqs] = /** @type {any[]} */ (await Promise.allSettled([libraryRescan(), STASH_URL ? stashCheck() : null, hqStatus()]));
if (lib.status === 'fulfilled') {
const roots = new Set(lib.value.map((it) => it.root || '(main folder)'));
logLine(`Library: ${lib.value.length} videos, ${lib.value.filter((it) => it.script).length} with Funscript, ${roots.size} folders – scanned in ${libraryScanMs()} ms`);
} else logLine(`Library: error – ${lib.reason?.message}`);
if (!STASH_URL) logLine('Stash: not configured');
else if (stash.value?.ok) logLine(`Stash: connected (${STASH_URL}, version ${stash.value.version || '?'})`);
else logLine(`Stash: NOT reachable (${STASH_URL}) – ${stash.value?.error || stash.reason?.message || 'unknown'}`);
if (stash.value?.code === 'forbidden') logStash403(stash.value);
if (STASH_URL) {
if (!stash.value?.ok) logLine('Stash: trying again every 30 s …');
startStashMonitor();
}
logLine(`HQ stream: ${hqs.value?.available ? (hqs.value.accel === 'vaapi' ? 'Intel graphics (VAAPI)' : 'CPU') : 'not available (no ffmpeg)'}`);
const gpu = hqs.value?.gpu;
if (gpu) logLine(`Graphics: ${GPU_TEXT[gpu.state]}${gpu.error ? ' – ' + gpu.error : ''}`);
if (UPDATE_OFF) logLine('Update check: off (UPDATE_CHECK=off)');
// test14: connection key / Application ID from older versions → encrypt them now
if (await prefsEncryptStored().catch(() => false)) logLine('Connection key: now stored encrypted');
logLine(`Connection key: encrypted with ${process.env.DATA_SECRET ? 'DATA_SECRET' : 'data/.secret'}`);
const checks = await runSelfcheck().catch((e) => [{ name: 'Self-test', ok: false, info: e.message }]);
const bad = checks.filter((c) => !c.ok);
logLine(`Self-test: ${checks.length - bad.length}/${checks.length} ok${bad.length ? '' : ' (server, interface, script intensity)'}`);
for (const c of bad) logLine(` ✗ ${c.name}${c.info ? ' – ' + c.info : ''}`);
logLine(`HereSphere: ${heresphereOn() ? (serverSettings.hsPass ? 'on, with password' : 'on') : 'off'} · Intiface: ${intifaceLanOnly() ? 'home network only' : 'private addresses'}`);
logLine('✓ ready');
});