/** * SFTP SSH Keys capture — the new customer-facing key management page * (WHP release 2026.09.11), as the demo customer. * * Captures: * - whp-sftp-keys-empty.png Authorized Keys list with no keys yet * (the real empty state). * - whp-sftp-keys-add-form.png the Add Key form filled in with a * throwaway demo key, before submitting. * - whp-sftp-keys-list.png Authorized Keys list after the key was * added — fingerprint/type/comment/added * columns populated. * * This is a documentation-owned demo account (demo-user), so unlike the other * capture-*.ts scripts, this one DOES submit the Add Key / Remove actions — * that's the only way to show the populated list state, and the account is * reset back to empty at the end of the run (no key is left behind). * * Viewport-only (1440x900, deviceScaleFactor 2), redacted for our multi-server * fleet: server hostnames/IPs become placeholders, while the brand demo * account (demo-user) is kept visible on purpose. */ import { chromium, type Page } from 'playwright'; import { mkdir } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp'); function need(name: string): string { const v = process.env[name]; if (!v) throw new Error(`missing env: ${name}`); return v; } const BASE = need('WHP_BASE'); const USER = need('WHP_USER'); const PASS = need('WHP_PASS'); const HIDE_CSS = `.navbar-text, .brand-full { visibility: hidden !important; }`; async function login(page: Page) { await page.goto(`${BASE}/login.php`, { waitUntil: 'domcontentloaded' }); await page.fill('input[name="user"]', USER); await page.fill('input[name="password"]', PASS); await page.click('button[type="submit"]'); await page.waitForLoadState('networkidle'); } async function redact(page: Page) { await page.addStyleTag({ content: HIDE_CSS }); await page.evaluate(() => { const swaps: [RegExp, string][] = [ [/ns[12]\.whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, 'ns..cloud-hosting.io'], [/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '.cloud-hosting.io'], [/mail\d+\.cloud-hosting\.io/gi, '.cloud-hosting.io'], [/WHP\d+(-[A-Z0-9]+)?\b/g, ''], [/whp\d+(-[a-z0-9]+)?\b/gi, ''], [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, ''], ]; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const nodes: Text[] = []; let n: Node | null = walker.nextNode(); while (n) { nodes.push(n as Text); n = walker.nextNode(); } for (const node of nodes) { let v = node.nodeValue ?? ''; for (const [re, rep] of swaps) v = v.replace(re, rep); if (v !== node.nodeValue) node.nodeValue = v; } document.querySelectorAll('input, textarea').forEach((el) => { if ((el as HTMLInputElement).type === 'password' || !el.value) return; let v = el.value; const swaps2: [RegExp, string][] = [ [/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '.cloud-hosting.io'], [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, ''], ]; for (const [re, rep] of swaps2) v = v.replace(re, rep); if (v !== el.value) el.value = v; }); }); } async function shot(page: Page, id: string) { await page.waitForTimeout(400); await redact(page); const path = resolve(OUT_DIR, `${id}.png`); await page.screenshot({ path, fullPage: false }); console.log(`captured ${id} -> ${path}`); } async function main() { await mkdir(OUT_DIR, { recursive: true }); // Throwaway ed25519 keypair, generated fresh for this capture only. Never // used to actually connect; discarded with the OS temp dir. const os = await import('node:os'); const fs = await import('node:fs/promises'); const tmpDir = await fs.mkdtemp(resolve(os.tmpdir(), 'kb-sftp-demo-key-')); const keyPath = resolve(tmpDir, 'id_ed25519'); execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', "dana's-laptop", '-f', keyPath]); const pubKey = (await fs.readFile(`${keyPath}.pub`, 'utf8')).trim(); const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, }); const page = await ctx.newPage(); try { await login(page); // 1. SFTP SSH Keys page — empty state await page.goto(`${BASE}/index.php?page=sftp-keys`, { waitUntil: 'networkidle' }); await page.waitForTimeout(600); await shot(page, 'whp-sftp-keys-empty'); // 2. Add Key form, filled in but not yet submitted await page.fill('#sftp-key-input', pubKey); await page.fill('#sftp-key-note', "Dana's laptop"); await shot(page, 'whp-sftp-keys-add-form'); // 3. Submit, then capture the populated list await page.click('#sftp-keys-add-btn'); await page.waitForTimeout(1200); await shot(page, 'whp-sftp-keys-list'); // Clean up: remove the demo key so the account is left as it was found. page.once('dialog', (d) => d.accept()); await page.locator('button:has-text("Remove")').first().click(); await page.waitForTimeout(1000); console.log('cleanup: demo key removed'); } finally { await browser.close(); await fs.rm(tmpDir, { recursive: true, force: true }); } } main().catch((err) => { console.error(err); process.exit(1); });