Files
shadowdaoandClaude Sonnet 5 f1dac00138 Add how-to page for the new SFTP SSH key management feature
Documents the customer-facing SFTP SSH Keys page shipped in WHP 2026.09.11
(sidebar: Security → SFTP SSH Keys) — adding/removing SSH public keys that
authorize SFTP login alongside the account password, useful for teams
sharing one hosting account without sharing a password. Walked the live
page as the demo customer account and captured real redacted screenshots
(empty state, filled Add Key form, populated key list). Also documents a
non-obvious live-verified detail: the visible Comment column reflects the
key's own comment (ssh-keygen -C), not the separate Note field, which only
goes to the audit log. Cross-linked from Create a site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 09:33:08 -07:00

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); });