142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
/**
|
|||
|
|
* 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<n>.<your-server>.cloud-hosting.io'],
|
||
|
|
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
|
||
|
|
[/mail\d+\.cloud-hosting\.io/gi, '<mail-server>.cloud-hosting.io'],
|
||
|
|
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
|
||
|
|
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
|
||
|
|
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<server-IP>'],
|
||
|
|
];
|
||
|
|
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<HTMLInputElement | HTMLTextAreaElement>('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, '<your-server>.cloud-hosting.io'],
|
||
|
|
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<server-IP>'],
|
||
|
|
];
|
||
|
|
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); });
|