website refresh: true numbers (56 apps), claims checked against the capability map, ten missing apps, an English twin of every public page (public, choice B), 404 page; site gates 13-18 + 11 decoys; R-902 opened
gates / gates (push) Failing after 13m1s

Part A: claim table documentation/audits/website-refresh-2026-10-08/claims.md; "100% open source" corrected
from the licence read; unbacked claims cut (firewall, RAID, snapshots, new-machine restore, self-managed
mode, household VPN, "never lost"). Part B: dawarich, docmost, grimmory, homebox, karakeep, mealie, metube,
radicale, recipe-importer, sparkyfitness cards; plant-it and wger cut (not offered). Part C: /en/ twins,
nav language switch, hreflang both ways, sitemap with xhtml:link, og-image-en.png. Part D: site_gates.py
twins/lang/same-apps/no-Hungarian/FAQ-JSON-LD/tail. Operator ruling 9 + choice B in 10-localisation.md §11,
§10.7. Register: R-902 (contact mailer source in no repo); R-813, R-784, R-559 annotated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-08 08:29:17 +02:00
parent b119301c6f
commit 67b3fd23d1
115 changed files with 9846 additions and 279 deletions
@@ -0,0 +1,123 @@
// Docmost screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own traefik,
// passing the setup gate as the household. Docmost's own first-run setup creates the invented workspace and its first
// (admin) account; a few INVENTED household pages are added through Docmost's own Markdown import; then its own UI
// is shot. (Docmost 0.96 ships no Hungarian interface; the pages are Hungarian.)
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
const PAGES = [
['Házirend.md', `# Házirend
Közös szabályok, hogy mindenki tudja, mi hol van és mi a teendő.
## Hétköznap
- A mosogatógépet az indítja el, aki utoljára tesz bele.
- A szelektív hulladék kedden és pénteken megy ki.
- Este 10 után csendes pihenő.
## Hétvégén
| Feladat | Ki? |
|---|---|
| Porszívózás | Anna |
| Fűnyírás | Péter |
| Bevásárlás | közösen |
`],
['Wifi és eszközök.md', `# Wifi és eszközök
Vendégeknek a **Vendég** hálózat való, a jelszó a hűtőn van.
## Eszközök
- Nyomtató: a dolgozószobában, hálózaton elérhető
- Okostévé: a nappaliban
- Router: az előszobai szekrényben
> Ha valami nem működik: először újraindítás, utána szólj.
`],
['Nyaralás 2027.md', `# Nyaralás 2027
## Ötletek
1. Balaton, Tihany – kerékpártúra a part mentén
2. Mátra – túrázás, kilátók
3. Őrség – csend, erdő, tökmagolaj
## Csomaglista
- [ ] Személyi igazolványok
- [ ] Naptej
- [ ] Elsősegélycsomag
- [ ] Töltők
`],
['Receptek, amiket szeretünk.md', `# Receptek, amiket szeretünk
- Rakott krumpli – vasárnap
- Gulyásleves – vendégeknek
- Palacsinta – mindig
`],
['Autó karbantartás.md', `# Autó karbantartás
| Mikor | Mi | Megjegyzés |
|---|---|---|
| 2026. március | Olajcsere | 15 000 km |
| 2026. október | Téli gumi | szerviz előre bejelentkezve |
| 2027. január | Műszaki vizsga | |
`],
];
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('landed', p.url().replace(URL, ''));
if (!S.skipseed) {
const r = await p.evaluate(async (S, PAGES) => {
const out = [];
const J = async (u, body) => { const x = await fetch(u, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {})}); return [x.status, await x.text()]; };
const [sc, st] = await J('/api/auth/setup', {name: S.name, email: S.user, password: S.pw, workspaceName: 'Demo Család'});
out.push(['setup', sc, sc >= 300 ? st.slice(0, 160) : '']);
const [lc, lt] = await J('/api/spaces', {page: 1, limit: 20});
const sp = JSON.parse(lt); const items = (sp.data && sp.data.items) || sp.items || [];
out.push(['spaces', lc, items.map(x => x.name).join(',')]);
const spaceId = items[0] && items[0].id;
for (const [fn, md] of PAGES) {
const fd = new FormData();
fd.append('spaceId', spaceId);
fd.append('file', new File([md], fn, {type: 'text/markdown'}));
const x = await fetch('/api/pages/import', {method: 'POST', body: fd});
out.push([fn, x.status, x.status >= 300 ? (await x.text()).slice(0, 120) : '']);
}
return out;
}, S, PAGES);
console.log(JSON.stringify(r));
}
await p.goto(URL + '/home', {waitUntil: 'networkidle2'});
await sleep(3000);
console.log('home', p.url().replace(URL, ''));
if (await p.$('input[type=password]')) { // not signed in by the setup call: Docmost's own sign-in form
await p.type('input[type=email]', S.user);
await p.type('input[type=password]', S.pw);
await p.keyboard.press('Enter');
await p.waitForNavigation({waitUntil: 'networkidle2', timeout: 30000}).catch(() => {});
await sleep(3000);
console.log('after sign-in', p.url().replace(URL, ''));
}
await p.screenshot({path: '/out/1.png'});
await p.click('::-p-text(Házirend)').catch(e => console.log('no page link', e.message));
await sleep(4000);
console.log('shot 2 at', p.url().replace(URL, ''));
await p.screenshot({path: '/out/2.png'});
const pageUrl = p.url();
await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2});
await p.goto(pageUrl, {waitUntil: 'networkidle2'}).catch(() => {});
await sleep(3000);
await p.screenshot({path: '/out/3.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,3 @@
# source me: the two env vars wr.py needs (SC holds only 0600 files; EV is this evidence dir)
export SC=/tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/a8c3ad67-72de-408f-9706-5ad993306ff7/scratchpad/shots
export EV=/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/website-refresh-2026-10-08/shots
@@ -0,0 +1,125 @@
// Homebox screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own traefik
// (every name mapped to it), passing the setup gate as the household (the dashboard session vouches). An invented
// demo account and a few invented things are created through Homebox's own API in the page, then its own UI is shot.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('landed', p.url().replace(/\?.*/, ''));
const r = await p.evaluate(async (S) => {
const j = (m, u, body, tok) => fetch(u, {method: m, headers: Object.assign({'Content-Type': 'application/json'},
tok ? {Authorization: tok} : {}), body: body ? JSON.stringify(body) : undefined}).then(async x => [x.status, await x.text()]);
const out = [];
if (S.skipseed) return out;
const jp = t => { try { return JSON.parse(t); } catch (e) { return {}; } };
out.push(['register', ...(await j('POST', '/api/v1/users/register', {name: S.name, email: S.user, password: S.pw})).map(x => String(x).slice(0, 80))]);
const [lc, lb] = await j('POST', '/api/v1/users/login', {username: S.user, password: S.pw, stayLoggedIn: true});
out.push(['login', lc]);
let tok;
try { tok = JSON.parse(lb).token; } catch (e) { return out.concat([['loginbody', lb.slice(0, 80)]]); }
const [tc, tt] = await j('GET', '/api/v1/entity-types', null, tok);
const types = jp(tt); out.push(['types', tc, tt.slice(0, 300)]);
const tl = (Array.isArray(types) ? types : (types.items || [])).find(x => x.isLocation) || {};
const ti = (Array.isArray(types) ? types : (types.items || [])).find(x => !x.isLocation) || {};
const loc = {};
for (const [n, d] of [['Nappali', 'Földszint'], ['Konyha', 'Földszint'], ['Garázs', 'Udvar'], ['Dolgozószoba', 'Emelet']]) {
const [c, t] = await j('POST', '/api/v1/entities', {name: n, description: d, entityTypeId: tl.id}, tok); loc[n] = jp(t).id; out.push(['loc', c, c >= 300 ? t.slice(0, 80) : '']);
}
const lab = {};
for (const [n, col] of [['Elektronika', '#3b82f6'], ['Garanciális', '#16a34a'], ['Szerszám', '#ea580c']]) {
const [c, t] = await j('POST', '/api/v1/tags', {name: n, color: col}, tok); lab[n] = jp(t).id; out.push(['label', c, c >= 300 ? t.slice(0, 80) : '']);
}
const items = [
['Mosogatógép', 'Beépített, 60 cm', 'Konyha', ['Elektronika', 'Garanciális']],
['Televízió 55"', 'Nappali fali konzolon', 'Nappali', ['Elektronika', 'Garanciális']],
['Akkus fúró-csavarozó', '18 V, két akkumulátorral', 'Garázs', ['Szerszám']],
['Létra, 3 fokos', 'Alumínium', 'Garázs', ['Szerszám']],
['Laptop', 'Munkához', 'Dolgozószoba', ['Elektronika']],
['Kávéfőző', 'Kapszulás', 'Konyha', ['Elektronika']],
['Porszívó', 'Vezeték nélküli', 'Nappali', ['Elektronika', 'Garanciális']],
['Fűnyíró', 'Elektromos', 'Garázs', ['Szerszám', 'Garanciális']],
];
for (const [n, d, l, ls] of items) {
const [c, t] = await j('POST', '/api/v1/entities', {name: n, description: d, entityTypeId: ti.id, parentId: loc[l], tagIds: ls.map(x => lab[x])}, tok);
out.push(['item', c, c >= 300 ? t.slice(0, 120) : '']);
}
return out;
}, S);
console.log(JSON.stringify(r));
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(1500);
await p.screenshot({path: '/out/login.png'});
if (process.env.STOP) { await b.close(); return; }
if (await p.$('input[type=password]')) { // the seed's API sign-in normally leaves the browser signed in already
const email = await p.$('input[type=email], input[name=email], input[type=text]');
if (email) await email.type(S.user);
await p.type('input[type=password]', S.pw);
await p.keyboard.press('Enter');
await p.waitForNavigation({waitUntil: 'networkidle2', timeout: 30000}).catch(() => {});
}
await sleep(2000);
// Homebox's own language preference (its Profile page writes it to localStorage): Hungarian
console.log('prefs before', await p.evaluate(() => JSON.stringify(Object.keys(localStorage))));
await p.evaluate(() => {
const k = 'homebox/preferences/location';
let v = {}; try { v = JSON.parse(localStorage.getItem(k) || '{}'); } catch (e) {}
v.language = 'hu'; localStorage.setItem(k, JSON.stringify(v));
});
if (S.cleanup) {
// Homebox's own default English locations and tags (empty) removed so the demo reads in one language; the
// collection's currency set to forint; invented prices and quantities on the invented things.
const r = await p.evaluate(async () => {
const out = [];
const J = async (m, u, b) => { const x = await fetch(u, {method: m, headers: {'Content-Type': 'application/json'}, body: b ? JSON.stringify(b) : undefined}); return [x.status, await x.text()]; };
const mine = ['Nappali', 'Konyha', 'Garázs', 'Dolgozószoba', 'Elektronika', 'Garanciális', 'Szerszám'];
const tree = JSON.parse((await J('GET', '/api/v1/entities/tree'))[1]);
for (const n of tree) if (!mine.includes(n.name) && !(n.children || []).length) out.push(['del loc ' + n.name, (await J('DELETE', '/api/v1/entities/' + n.id))[0]]);
const tags = JSON.parse((await J('GET', '/api/v1/tags'))[1]);
for (const t of (Array.isArray(tags) ? tags : tags.items || [])) if (!mine.includes(t.name)) out.push(['del tag ' + t.name, (await J('DELETE', '/api/v1/tags/' + t.id))[0]]);
const g = JSON.parse((await J('GET', '/api/v1/groups'))[1]);
out.push(['currency', (await J('PUT', '/api/v1/groups', {name: g.name, currency: 'HUF'}))[0]]);
const price = {'Mosogatógép': 189990, 'Televízió 55"': 249990, 'Akkus fúró-csavarozó': 42990, 'Létra, 3 fokos': 12990,
'Laptop': 329990, 'Kávéfőző': 34990, 'Porszívó': 89990, 'Fűnyíró': 64990};
const list = JSON.parse((await J('GET', '/api/v1/entities?pageSize=100'))[1]);
for (const it of (list.items || [])) {
if (!(it.name in price)) continue;
const [c, full] = await J('GET', '/api/v1/entities/' + it.id);
const o = JSON.parse(full);
o.purchasePrice = price[it.name]; o.quantity = 1; o.insured = /Televízió|Laptop|Mosogatógép/.test(it.name);
o.tagIds = (o.tags || []).map(t => t.id); o.parentId = o.parent ? o.parent.id : o.parentId;
const [pc, pt] = await J('PUT', '/api/v1/entities/' + it.id, o);
out.push(['price ' + it.name, pc, pc >= 300 ? pt.slice(0, 120) : '']);
}
return out;
});
console.log(JSON.stringify(r));
}
await p.reload({waitUntil: 'networkidle2'});
await sleep(3000);
console.log('after login', p.url());
await p.screenshot({path: '/out/1.png'});
await p.click('::-p-text(Fűnyíró)').catch(e => console.log('no tv row', e.message));
await sleep(3000);
console.log('shot 2 at', p.url().replace(/[0-9a-f-]{36}/, '<id>'));
await p.screenshot({path: '/out/2.png'});
await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2});
await p.goto(URL + '/home', {waitUntil: 'networkidle2'}).catch(() => {});
await p.evaluate(() => { // a full navigation put the language back to English: set it again, as on the desktop
const k = 'homebox/preferences/location';
let v = {}; try { v = JSON.parse(localStorage.getItem(k) || '{}'); } catch (e) {}
v.language = 'hu'; localStorage.setItem(k, JSON.stringify(v));
});
await p.reload({waitUntil: 'networkidle2'});
await sleep(3000);
await p.screenshot({path: '/out/3.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,25 @@
// Mealie probe (no writes): after the API sign-in, what the box holds and what the frontend keeps for language.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU', '--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.goto(URL + '/login', {waitUntil: 'networkidle2'});
const r = await p.evaluate(async (pw) => {
const fd = new URLSearchParams({username: 'changeme@example.com', password: pw, remember_me: 'true'});
const st = (await fetch('/api/auth/token', {method: 'POST', body: fd})).status;
const g = async (u, n = 400) => { const x = await fetch(u); return [x.status, (await x.text()).slice(0, n)]; };
const rec = await g('/api/recipes?perPage=50', 1e6);
return {st, recipes: rec[0] + ' ' + (rec[1].match(/"total":\d+/) || [''])[0], names: (rec[1].match(/"name":"[^"]+"/g) || []).join(','),
startup: await g('/api/app/about/startup-info'), self: Object.keys(JSON.parse((await g('/api/users/self', 1e6))[1] || '{}'))};
}, S.gen.ADMIN_PASSWORD);
console.log(JSON.stringify(r).replace(/"(access_)?tok[a-z_]*":"[^"]*"/gi, '"…":"…"'));
await p.goto(URL + '/g/home', {waitUntil: 'networkidle2'});
await sleep(2000);
console.log('g/home ->', p.url(), JSON.stringify(await p.evaluate(() => Object.keys(localStorage))), JSON.stringify((await p.cookies()).map(c => c.name)));
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,120 @@
// Mealie screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own traefik,
// passing the setup gate as the household. The first account (changeme@example.com, the password this install
// generated) adds a few INVENTED recipes and a week's meal plan through Mealie's own API, then its own UI is shot.
// No recipe site is contacted.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
const RECIPES = [
['Rakott krumpli', 'Réteges, tejfölös, kolbászos egytálétel a hétvégére.', '6 adag', '1 óra 30 perc',
['1,5 kg burgonya', '8 tojás', '300 g parasztkolbász', '500 g tejföl', 'só, bors, vaj'],
['A burgonyát és a tojásokat héjában megfőzzük, majd felkarikázzuk.', 'Kivajazott tálba rétegezzük a burgonyát, a tojást és a kolbászt.',
'Minden réteget megkenünk tejföllel, a tetejére is tejföl kerül.', '180 fokon kb. 40 percig sütjük.']],
['Gulyásleves', 'Bográcsban vagy fazékban, ahogy a nagymama főzte.', '8 adag', '2 óra',
['800 g marhalábszár', '2 fej vöröshagyma', '2 sárgarépa', '1 fehérrépa', '600 g burgonya', 'pirospaprika, kömény'],
['A hagymát zsíron üvegesre pároljuk, rászórjuk a pirospaprikát.', 'Hozzáadjuk a felkockázott húst, és fehéredésig pirítjuk.',
'Felöntjük vízzel, és fedő alatt puhára főzzük.', 'Hozzáadjuk a zöldségeket és a burgonyát, készre főzzük.']],
['Túrós csusza', 'Gyors vacsora tepertővel.', '4 adag', '30 perc',
['500 g széles metélt', '500 g tehéntúró', '200 g tejföl', '150 g tepertő'],
['A tésztát sós vízben kifőzzük.', 'A tepertőt kicsit megpirítjuk.', 'A tésztára túrót, tejfölt és tepertőt szórunk.']],
['Meggyes pite', 'Linzeres tészta, savanykás meggytöltelék.', '12 szelet', '1 óra 15 perc',
['500 g liszt', '250 g vaj', '150 g porcukor', '2 tojás', '800 g magozott meggy', '3 ek darált keksz'],
['A tészta hozzávalóit összegyúrjuk, és fél órára hűtőbe tesszük.', 'A tészta felét tepsibe nyújtjuk, megszórjuk keksszel.',
'Ráterítjük a meggyet, befedjük a másik tésztalappal.', '180 fokon aranybarnára sütjük.']],
['Lecsó', 'Nyári klasszikus, tojással vagy kolbásszal.', '4 adag', '40 perc',
['1 kg TV paprika', '500 g paradicsom', '2 fej vöröshagyma', 'só, pirospaprika'],
['A hagymát megpároljuk.', 'Hozzáadjuk a felcsíkozott paprikát, majd a paradicsomot.', 'Sűrűre főzzük.']],
['Palacsinta', 'Lekvárral, kakaóval vagy túróval.', '15 darab', '45 perc',
['250 g liszt', '3 tojás', '5 dl tej', '2 dl szódavíz', 'csipet só', 'olaj a sütéshez'],
['A hozzávalókból csomómentes tésztát keverünk.', 'Fél órát pihentetjük.', 'Forró serpenyőben vékonyra kisütjük.']],
];
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/login', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('landed', p.url().replace(/\?.*/, ''));
if (!S.skipseed) {
const r = await p.evaluate(async (S, RECIPES) => {
const out = [];
const fd = new URLSearchParams({username: 'changeme@example.com', password: S.gen.ADMIN_PASSWORD});
const t = await fetch('/api/auth/token', {method: 'POST', body: fd});
out.push(['auth', t.status]);
if (t.status !== 200) return out;
const tok = 'Bearer ' + (await t.json()).access_token;
const J = async (m, u, body) => { const x = await fetch(u, {method: m, headers: {'Content-Type': 'application/json', Authorization: tok}, body: body ? JSON.stringify(body) : undefined}); return [x.status, await x.text()]; };
const ids = [];
for (const [name, desc, yld, total, ing, steps] of RECIPES) {
const [c, slugTxt] = await J('POST', '/api/recipes', {name});
const slug = JSON.parse(slugTxt);
const [gc, full] = await J('GET', '/api/recipes/' + slug);
const o = JSON.parse(full);
o.description = desc; o.recipeYield = yld; o.totalTime = total;
o.recipeIngredient = ing.map(n => ({note: n, display: n, quantity: 0, disableAmount: true}));
o.recipeInstructions = steps.map(s => ({text: s, title: '', ingredientReferences: []}));
const [pc, pt] = await J('PUT', '/api/recipes/' + slug, o);
out.push([name, c, gc, pc, pc >= 300 ? pt.slice(0, 160) : '']);
ids.push(o.id);
}
// a week's meal plan from today
const d0 = new Date();
for (let i = 0; i < 6; i++) {
const d = new Date(d0.getTime() + i * 86400000).toISOString().slice(0, 10);
const [mc, mt] = await J('POST', '/api/households/mealplans', {date: d, entryType: 'dinner', recipeId: ids[i % ids.length]});
out.push(['plan ' + d, mc, mc >= 300 ? mt.slice(0, 120) : '']);
}
// the account's display name and language
const [uc, ut] = await J('GET', '/api/users/self');
const u = JSON.parse(ut);
return out.concat([['self', uc, u.fullName, u.locale || u.language || '']]);
}, S, RECIPES);
console.log(JSON.stringify(r));
}
// sign in: Mealie's /api/auth/token answer sets its own session cookie in this browser (no form typing: the login
// page pre-fills the demo defaults over typed text, and five wrong passwords lock the account for hours)
const st = await p.evaluate(async (pw) => {
const fd = new URLSearchParams({username: 'changeme@example.com', password: pw, remember_me: 'true'});
return (await fetch('/api/auth/token', {method: 'POST', body: fd, credentials: 'same-origin'})).status;
}, S.gen.ADMIN_PASSWORD);
console.log('sign-in status', st);
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(2000);
if (p.url().includes('/admin/setup')) { // Mealie's first-login wizard: its own "already set up" button
await p.click("::-p-text(I'm already set up)").catch(e => console.log('no skip button', e.message));
await sleep(3000);
console.log('after setup skip', p.url().replace(URL, ''));
}
if (S.rename) { // the first account's display name: an invented household name instead of "Change Me"
console.log('rename', await p.evaluate(async () => {
const u = await (await fetch('/api/users/self')).json();
u.fullName = 'Demo Család';
return (await fetch('/api/users/' + u.id, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(u)})).status;
}));
}
// Mealie's own language switch keeps the choice in this cookie: Hungarian
await p.setCookie({name: 'i18n_redirected', value: 'hu-HU', domain: `${process.env.SUB}.${D}`, path: '/', secure: true});
await p.goto(URL + '/g/home', {waitUntil: 'networkidle2'});
await sleep(4000);
console.log('after login', p.url().replace(URL, ''));
await p.screenshot({path: '/out/1.png'});
const home = p.url();
await p.click('::-p-text(Rakott krumpli)').catch(e => console.log('no recipe card', e.message));
await sleep(4000);
console.log('shot 2 at', p.url().replace(URL, ''));
await p.screenshot({path: '/out/2.png'});
const recipeUrl = p.url();
await p.goto(URL + '/household/mealplan/planner/view', {waitUntil: 'networkidle2'}).catch(() => {});
await sleep(3000);
console.log('shot 3 at', p.url().replace(URL, ''));
await p.screenshot({path: '/out/3.png'});
await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2});
await p.goto(recipeUrl, {waitUntil: 'networkidle2'}).catch(() => {});
await sleep(3000);
await p.screenshot({path: '/out/4.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,46 @@
// Recipe Importer screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own
// traefik, passing the setup gate as the household. No recipe site is contacted (a scraped recipe would be a third
// party's text and picture). The app's own settings form is saved with an INVENTED Mealie address and a placeholder
// key (no Mealie exists behind it), which is what opens its import page; the shots are its import and settings pages.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
const DEMO_URL = 'https://www.example.com/receptek/rakott-krumpli';
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/settings', {waitUntil: 'networkidle2'});
await sleep(1000);
const set = async (sel, v) => { const f = await p.$(sel); if (f) { await f.click({clickCount: 3}); await f.type(v); } else console.log('no field', sel); };
await set('input[name=mealie_url]', 'https://recipes.example.hu');
await set('input[name=mealie_api_key]', 'demo-placeholder');
await Promise.all([p.waitForNavigation({waitUntil: 'networkidle2'}).catch(() => {}),
p.$eval('input[name=mealie_url]', el => el.form.submit())]);
await sleep(1000);
console.log('after save', p.url());
await p.screenshot({path: '/out/settings.png'});
await p.goto(URL + '/import', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('import ->', p.url());
const ta = await p.$('textarea, input[type=url], input[name=url]');
if (ta) await ta.type(DEMO_URL); else console.log('no url field');
await sleep(500);
await p.screenshot({path: '/out/import.png'});
// the bulk tab: several invented addresses at once
await p.click('::-p-text(Tömeges importálás)').catch(e => console.log('no bulk tab', e.message));
await sleep(800);
const bulk = await p.$$('textarea');
const vis = [];
for (const t of bulk) if (await t.isVisible()) vis.push(t);
if (vis.length) await vis[0].type(['https://www.example.com/receptek/rakott-krumpli', 'https://www.example.com/receptek/gulyasleves',
'https://www.example.com/receptek/turos-csusza', 'https://www.example.com/receptek/meggyes-pite'].join('\n'));
else console.log('no visible bulk textarea');
await sleep(500);
await p.screenshot({path: '/out/bulk.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,108 @@
// SparkyFitness screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own
// traefik, passing the setup gate as the household. An INVENTED demo account signs up through SparkyFitness's own
// sign-up (open while the setup gate is up), then its own UI is shot. Better-auth rate-limits sign-in per IP: a 429
// is waited out, never read as a verdict.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('landed', p.url().replace(URL, ''));
const auth = await p.evaluate(async (S) => {
const J = async (u, body) => { const x = await fetch(u, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); return [x.status, (await x.text()).slice(0, 160)]; };
let r = S.skipseed ? [0, ''] : await J('/api/auth/sign-up/email', {email: S.user, password: S.pw, name: S.name});
const out = [['sign-up', r[0], r[0] >= 300 ? r[1] : '']];
r = await J('/api/auth/sign-in/email', {email: S.user, password: S.pw});
out.push(['sign-in', r[0], r[0] >= 300 ? r[1] : '']);
return out;
}, S);
console.log(JSON.stringify(auth));
// SparkyFitness keeps the language in the account's own preferences (Settings): Hungarian, and a Hungarian date format
console.log('lang ->', await p.evaluate(async () => {
const res = [];
for (const m of ['PUT', 'POST']) {
const x = await fetch('/api/user-preferences', {method: m, headers: {'Content-Type': 'application/json'}, body: JSON.stringify({language: 'hu', date_format: 'yyyy-MM-dd'})});
res.push(m + ' ' + x.status); if (x.ok) break;
}
return res.join(', ');
}));
await p.evaluate(() => { localStorage.setItem('i18nextLng', 'hu'); localStorage.setItem('language', 'hu');
localStorage.setItem('dismissedReleaseVersion', 'v1.8.0'); });
if (S.seedfood) { // INVENTED foods, today's diary, and two weeks of weight check-ins, through the app's own API
console.log('seed', JSON.stringify(await p.evaluate(async () => {
const out = [];
const J = async (m, u, b) => { const x = await fetch(u, {method: m, headers: {'Content-Type': 'application/json'}, body: b ? JSON.stringify(b) : undefined}); return [x.status, await x.text()]; };
const today = new Date().toISOString().slice(0, 10);
const foods = [['Zabpehely', 100, 'g', 372, 13, 59, 7, 'breakfast', 60], ['Banán', 100, 'g', 89, 1.1, 23, 0.3, 'breakfast', 120],
['Csirkemell, sült', 100, 'g', 165, 31, 0, 3.6, 'lunch', 180], ['Jázmin rizs, főtt', 100, 'g', 130, 2.7, 28, 0.3, 'lunch', 150],
['Görög joghurt', 100, 'g', 97, 9, 4, 5, 'snacks', 150], ['Lencsefőzelék', 100, 'g', 116, 9, 20, 0.4, 'dinner', 300]];
for (const [name, ss, su, cal, pr, cb, fat, meal, qty] of foods) {
const [fc, ft] = await J('POST', '/api/foods', {name, serving_size: ss, serving_unit: su, calories: cal, protein: pr, carbs: cb, fat, is_custom: true});
let f = {}; try { f = JSON.parse(ft); } catch (e) {}
const vid = f.default_variant_id || (f.default_variant && f.default_variant.id) || f.variant_id || (f.variants && f.variants[0] && f.variants[0].id);
out.push([name, fc, fc >= 300 ? ft.slice(0, 120) : Object.keys(f).slice(0, 12).join(',')]);
let v = vid;
if (!v && f.id) { const [vc, vt] = await J('GET', '/api/foods/food-variants?food_id=' + f.id); try { const a = JSON.parse(vt); v = (Array.isArray(a) ? a : a.variants || [])[0].id; } catch (e) { out.push(['variants', vc, vt.slice(0, 120)]); } }
const [ec, et] = await J('POST', '/api/food-entries', {food_id: f.id, variant_id: v, meal_type: meal, quantity: qty, unit: su, entry_date: today});
out.push(['entry ' + name, ec, ec >= 300 ? et.slice(0, 160) : '']);
}
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
const w = Math.round((82.4 - (13 - i) * 0.18 + (i % 3 === 0 ? 0.3 : 0)) * 10) / 10;
const [cc, ct] = await J('POST', '/api/measurements/check-in', {entry_date: d, weight: w, waist: 92 - (13 - i) * 0.1, steps: 6000 + (i * 733) % 5000});
if (i === 13 || cc >= 300) out.push(['check-in ' + d, cc, cc >= 300 ? ct.slice(0, 160) : '']);
}
return out;
})));
}
const clickText = async (texts) => p.evaluate((texts) => { // a DOM click (the dialog's X is a visually-hidden label)
const b = [...document.querySelectorAll('button, a')].find(e => texts.includes(e.innerText.trim()));
if (b) { b.click(); return b.innerText.trim(); }
return null;
}, texts);
const closeModal = async () => { // the "new version available" notice: read to the end, then "don't show again"
await p.evaluate(() => document.querySelectorAll('[role=dialog] *').forEach(e => { if (e.scrollHeight > e.clientHeight + 5) e.scrollTop = e.scrollHeight; }));
await sleep(800);
console.log('dismiss ->', await clickText(["Don't show again for this version"]));
await sleep(1200);
};
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(3000);
await closeModal();
console.log('skip ->', await clickText(['Kihagyás', 'Skip'])); await sleep(3000);
await closeModal();
await sleep(2000);
console.log('home', p.url().replace(URL, ''));
if (S.water) { // a few glasses of water, with the diary's own "+" button
for (let k = 0; k < 5; k++) {
await p.evaluate(() => { const b = [...document.querySelectorAll('button')].filter(e => e.querySelector('svg.lucide-plus') && e.closest('div') && e.innerText.trim() === ''); if (b.length) b[b.length - 1].click(); });
await sleep(900);
}
}
await p.evaluate(() => window.scrollTo(0, 0));
await sleep(1500);
await p.screenshot({path: '/out/1.png'});
console.log('reports ->', await clickText(['Jelentések', 'Reports']));
await sleep(4000);
console.log('measurements ->', await clickText(['Measurements', 'Mérések', 'Testméretek']));
await sleep(4000);
await p.screenshot({path: '/out/2.png'});
await p.evaluate(() => window.scrollBy(0, 330));
await sleep(1500);
await p.screenshot({path: '/out/2b.png'});
console.log('diary ->', await clickText(['Napló', 'Diary']));
await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2});
await sleep(3000);
await p.evaluate(() => window.scrollTo(0, 0));
await sleep(1000);
await p.screenshot({path: '/out/3.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,17 @@
"""sw.py — website-refresh 2026-10-08 screenshot helper. A shim over the catalog's box client
(app-catalog-felhom.eu/scripts/box_walk.py, read-only), TARGET 9202, with ONE change: guest() pipes the script
into `pct exec 9202 -- bash -s` over `ssh hp`, so nothing is written on the demo-hp host itself (no pct push,
no temp file there). SC (0600 scratch with `.ctlpw`) and EV come from the environment. No secret is printed."""
import os, subprocess, sys
os.environ.setdefault("TARGET", "9202")
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as b # noqa: E402
def guest(script, timeout=600):
r = b.sh(["ssh", "-o", "ConnectTimeout=20", "-o", "BatchMode=yes", "hp",
"LC_ALL=C pct exec 9202 -- env LC_ALL=C bash -s"], timeout=timeout, inp=script)
return (r.stdout or "") + (("\n[stderr] " + r.stderr.strip()[-800:]) if r.returncode and r.stderr else "")
b.guest = guest
@@ -0,0 +1,13 @@
"""towebp.py <app> [quality] — raw/<n>.png in the evidence dir -> <app>-screenshot-<n>.webp in website/assets
(Pillow, method 6). Refuses to overwrite an existing asset."""
import os, sys
from PIL import Image
app = sys.argv[1]; q = int(sys.argv[2]) if len(sys.argv) > 2 else 80
EV = os.environ["EV"]; A = "/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
for n in (1, 2, 3):
src = f"{EV}/{app}/raw/{n}.png"; dst = f"{A}/{app}-screenshot-{n}.webp"
if os.path.exists(dst) and not os.environ.get("MINE"):
sys.exit(f"refused: {dst} exists")
im = Image.open(src).convert("RGB")
im.save(dst, "WEBP", quality=q, method=6)
print(dst, im.size, os.path.getsize(dst), "B")
@@ -0,0 +1,128 @@
"""wr.py — website-refresh 2026-10-08: install ONE catalog app on scratch guest 9202 through the product, take its
screenshots with headless Chrome inside 9202, copy the raw PNGs off the box, remove the app through the product,
verify it is gone.
python3 wr.py baseline
python3 wr.py deploy <app> <sub>
python3 wr.py shoot <app> <sub> <script.js> [KEY=VAL ...] # secrets.json in the guest, 0600, removed after
python3 wr.py fetch <app> <guest-path> <local-path> # one file off the guest
python3 wr.py remove <app>
python3 wr.py verify <app>
Env: SC (0600 scratch with .ctlpw), EV (evidence dir). No secret is ever printed: the dashboard session and the
invented demo account's password travel only inside files (SC on DooPlex, /root/wr-shots/<app>/secrets.json in the
guest, removed by `shoot` when Chrome exits)."""
import base64, json, os, secrets, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import sw # noqa: E402
b = sw.b
SC, EV = os.environ["SC"], os.environ["EV"]
IMG = "ghcr.io/puppeteer/puppeteer:latest"
TRAEFIK = "172.18.0.5"
APPS = ["docmost", "homebox", "mealie", "recipe-importer", "sparkyfitness"]
def log(app, *a):
b.say(*a)
os.makedirs(f"{EV}/{app}", exist_ok=True)
with open(f"{EV}/{app}/run.log", "a") as f:
f.write(" ".join(map(str, a)) + "\n")
def secfile(app):
p = f"{SC}/sec-{app}.json"
if os.path.exists(p):
return json.load(open(p))
d = {"user": "demo@example.com", "pw": "Demo-" + secrets.token_hex(10), "name": "Demo Csalad"}
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.write(fd, json.dumps(d).encode()); os.close(fd)
return d
def baseline():
b.login()
c, d = b.ctl("GET", "/api/stacks")
dep = [(s.get("name"), s.get("state")) for s in (d.get("data") or []) if isinstance(s, dict) and s.get("deployed")]
out = b.guest("df -h / | tail -1; docker ps --format '{{.Names}} {{.Image}}'; "
"docker volume ls --format '{{.Name}}' | grep -E 'docmost|homebox|mealie|recipe|sparky' || echo 'no volumes of the five'; "
+ "; ".join(f"echo {a}: $(ls -A /opt/docker/stacks/{a})" for a in APPS)
+ f"; docker inspect -f '{{{{.RepoDigests}}}} {{{{.Created}}}}' {IMG}")
txt = f"deployed (dashboard API): {dep}\n{out}"
os.makedirs(EV, exist_ok=True)
open(f"{EV}/baseline-9202.txt", "w").write(txt + "\n")
print(txt)
def deploy(app, sub):
b.login()
ok = b.deploy(app, sub)
if b.GENERATED.get(app):
p = f"{SC}/gen-{app}.json"
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.write(fd, json.dumps(b.GENERATED[app]).encode()); os.close(fd)
log(app, f"generated deploy secrets kept in scratch (keys {list(b.GENERATED[app])})")
log(app, f"deploy {app} -> {ok}")
if ok:
log(app, f"app answers: {b.wait_app(sub)}")
def shoot(app, sub, script, extra):
b.login()
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip().split("=", 1)[1]
sec = dict(secfile(app))
gp = f"{SC}/gen-{app}.json"
if os.path.exists(gp):
sec["gen"] = json.load(open(gp))
sec["session"] = sess
for kv in extra:
k, v = kv.split("=", 1); sec[k] = v
js = open(script).read()
gdir = f"/root/wr-shots/{app}"
payload = base64.b64encode(json.dumps(sec).encode()).decode()
jsb = base64.b64encode(js.encode()).decode()
cmd = f"""set -e
umask 077; mkdir -p {gdir}; chmod 700 {gdir}
echo {payload} | base64 -d > {gdir}/secrets.json; chmod 600 {gdir}/secrets.json
echo {jsb} | base64 -d > {gdir}/shot.js
set +e
timeout 600 docker run --rm --network traefik-public --user root -e PUPPETEER_CACHE_DIR=/home/pptruser/.cache/puppeteer \
-e NODE_PATH=/home/pptruser/node_modules -e DOMAIN=enkisfelhom.hu -e SUB={sub} -e TRAEFIK={TRAEFIK} -e STOP=${{STOP:-}} \
-v {gdir}:/out {IMG} node /out/shot.js 2>&1 | sed -E "s/[A-Za-z0-9_.+/=-]{{28,}}/<long-value-redacted>/g" | tail -40
rc=${{PIPESTATUS[0]}}
shred -u {gdir}/secrets.json
echo "chrome rc=$rc"; ls -la {gdir}
"""
out = b.guest(cmd, timeout=900)
log(app, out)
def fetch(app, gpath, lpath):
out = b.guest(f"base64 -w0 {gpath}")
data = base64.b64decode(out.strip().split("\n")[0])
os.makedirs(os.path.dirname(lpath), exist_ok=True)
open(lpath, "wb").write(data)
log(app, f"fetched {gpath} -> {lpath} ({len(data)} B)")
def remove(app):
b.login()
b.remove(app)
verify(app)
def verify(app):
b.login()
st = b.stack(app)
out = b.guest(f"echo containers: $(docker ps -a --format '{{{{.Names}}}}' | grep -i '{app.split('-')[0]}' || echo none); "
f"echo volumes: $(docker volume ls --format '{{{{.Name}}}}' | grep -i '{app.split('-')[0]}' || echo none); "
f"echo networks: $(docker network ls --format '{{{{.Name}}}}' | grep -i '{app.split('-')[0]}' || echo none); "
f"echo stackdir: $(ls -A /opt/docker/stacks/{app}); "
f"echo drive: $(ls -d /mnt/felhom-drives/scratch_hdd/userdata/{app} 2>/dev/null || echo none)")
log(app, f"VERIFY {app}: deployed={st.get('deployed')} state={st.get('state')}\n{out}")
if __name__ == "__main__":
a = sys.argv[1:]
{"baseline": lambda: baseline(), "deploy": lambda: deploy(a[1], a[2]),
"shoot": lambda: shoot(a[1], a[2], a[3], a[4:]), "fetch": lambda: fetch(a[1], a[2], a[3]),
"remove": lambda: remove(a[1]), "verify": lambda: verify(a[1])}[a[0]]()