Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2044d9e0bc | ||
|
|
9f30b6a2b3 | ||
|
|
6700ce27b8 | ||
|
|
9f8d05eec8 | ||
|
|
f1dac00138 |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 332 KiB |
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Release or allow quarantined email
|
||||
description: Get a message out of spam quarantine yourself, right away — no need to wait for the daily notice.
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||
import Support from '~/content/partials/support-link.mdx';
|
||||
|
||||
Mail our filters flag as likely spam isn't delivered to your inbox — it's held in **quarantine** instead, where you can review it, release anything that's actually wanted, and always allow a sender in future. You don't have to wait for the daily notice to do this — see below.
|
||||
|
||||
## Check your quarantine
|
||||
|
||||
You'll normally get a **daily summary email** listing anything held for you — but the steps below work any time, not just after that email arrives. There's no separate password to remember; a link emailed to your own address is what signs you in.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Go to your mail filtering portal at `https://pmg01.cloud-hosting.io:8006/quarantine`. Choose **Request Quarantine Link**.
|
||||
|
||||

|
||||
|
||||
2. Enter the email address you want to check.
|
||||
|
||||

|
||||
|
||||
3. Open the email that arrives — usually within moments — and follow the link inside it.
|
||||
|
||||
4. Review what's listed for your address.
|
||||
|
||||

|
||||
|
||||
5. Select a message to see its content and the Whitelist / Blacklist / Deliver / Delete options, then choose **Deliver** to send it to your inbox as normal.
|
||||
|
||||

|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
**Need something released right now?** This works any time of day, not just once daily — request a new link as often as you need one. No need to wait for the daily summary email.
|
||||
</Aside>
|
||||
|
||||
## Always allow a sender
|
||||
|
||||
If mail from someone keeps getting caught and you know it's legitimate, allow their address (or their whole domain) from the same screen — choose **Whitelist** on a held message (shown in the screenshot above), or add the address directly from the quarantine portal. Future mail from that sender skips spam filtering for **your address specifically** — it doesn't change anything for the rest of your organization.
|
||||
|
||||
<Aside type="caution">
|
||||
Only allow senders you actually recognize. Allowing an address skips spam checks for it entirely, so it's worth being deliberate rather than allowing everything that shows up.
|
||||
</Aside>
|
||||
|
||||
## Common problems
|
||||
|
||||
**The sign-in link email hasn't arrived.** Check your spam folder — slightly ironic for a spam-filtering tool, but it happens. If it's still missing after a few minutes, contact us.
|
||||
|
||||
**I can't find a message I expected.** Quarantine holds mail for a limited time before it's cleared automatically, so check soon after being notified rather than weeks later. If it's genuinely missing, contact us and we can check on our end.
|
||||
|
||||
**A sender I allowed is still landing in quarantine.** Double check you allowed the exact address (or domain) it actually sends from — some services send from a different address than the one they display, especially automated notifications.
|
||||
|
||||
## Related
|
||||
|
||||
- [Changes to spam filtering](/email/spam-filtering-changes/)
|
||||
- [Set up your email](/email/set-up-your-email-client/)
|
||||
|
||||
## Still stuck?
|
||||
|
||||
<Support />
|
||||
@@ -45,6 +45,7 @@ We'll update this page as the migration progresses.
|
||||
|
||||
## Related
|
||||
|
||||
- [Release or allow quarantined email](/email/release-quarantined-email/)
|
||||
- [Set up your email](/email/set-up-your-email-client/)
|
||||
- [Create an email account](/whp/how-to/create-an-email-account/)
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ When you connect via SFTP, your account home (`/docker/users/<your-user>/`) show
|
||||
|
||||
Upload your files to the right place and the site picks them up immediately.
|
||||
|
||||
Logging in with your account password works out of the box. If you'd rather use an SSH key instead — or need to let a teammate connect without sharing your password — see [Manage SFTP SSH keys](/whp/how-to/manage-sftp-ssh-keys/).
|
||||
|
||||
## Connect to databases and caches
|
||||
|
||||
<Hostnames />
|
||||
@@ -82,6 +84,7 @@ Open your domain in a browser. You should see your site, or the default "no cont
|
||||
|
||||
- [Add a domain](/whp/how-to/add-a-domain/)
|
||||
- [Service hostnames](/whp/reference/service-hostnames/)
|
||||
- [Manage SFTP SSH keys](/whp/how-to/manage-sftp-ssh-keys/)
|
||||
|
||||
## Still stuck?
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Manage SFTP SSH keys
|
||||
description: Add SSH public keys to your account so you — and anyone else on your team — can log in over SFTP without sharing a password.
|
||||
sidebar:
|
||||
order: 8
|
||||
---
|
||||
|
||||
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||
import SignIn from '~/content/partials/signing-in.mdx';
|
||||
import Support from '~/content/partials/support-link.mdx';
|
||||
|
||||
SFTP normally logs in with your account password. SSH keys give you (or a teammate) a second way in — no password typing, and no need to share your password with anyone. Add one key per person, and remove any key on its own without touching anyone else's.
|
||||
|
||||
## Before you start
|
||||
|
||||
- An SSH key pair. If you don't have one yet, the steps below cover generating one — it takes about a minute.
|
||||
- About 5 minutes.
|
||||
|
||||
<Aside type="tip">
|
||||
The main reason to use this: if more than one person needs SFTP access to the same hosting account, everyone can add their own key instead of all sharing one password. Removing someone's access later is just removing their key.
|
||||
</Aside>
|
||||
|
||||
## Sign in to WHP
|
||||
|
||||
<SignIn />
|
||||
|
||||
## Steps
|
||||
|
||||
<Steps>
|
||||
|
||||
1. In the sidebar, click **Security → SFTP SSH Keys**.
|
||||

|
||||
|
||||
2. If you don't already have an SSH key, generate one on your own machine:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "your-email-or-a-note-to-yourself"
|
||||
```
|
||||
|
||||
Accept the default file location (`~/.ssh/id_ed25519`) and set a passphrase if you'd like one. If you already have a key, you'll find it at `~/.ssh/id_ed25519.pub` (or `~/.ssh/id_rsa.pub` for an older RSA key).
|
||||
|
||||
<Aside type="caution">
|
||||
Only ever paste the **`.pub`** file's contents — that's the *public* key, safe to share. Never paste the file without `.pub` (or one that starts with `-----BEGIN OPENSSH PRIVATE KEY-----`) anywhere. The panel will refuse anything that looks like a private key, but it's worth knowing the difference.
|
||||
</Aside>
|
||||
|
||||
3. Copy the full contents of the `.pub` file — one line, starting with the key type (`ssh-ed25519`, `ssh-rsa`, etc.).
|
||||
|
||||
4. Paste it into the **Public key** box in the **Add Key** card. The **Note** field is optional — whatever you type there is recorded in the account's audit log, but it isn't what shows in the key list below (see the tip after this list for how to label a key so you can actually tell it apart later).
|
||||

|
||||
|
||||
5. Click **Add Key**. It appears in the **Authorized Keys** table immediately and works right away — no separate activation step.
|
||||

|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
The **Comment** column in the table comes from the key itself, not from the Note field — it's whatever followed the key type when the key was generated (the `-C "..."` value above, e.g. `your-email-or-a-note-to-yourself`). If several people are adding keys to the same account, agree on a naming convention for `-C` (e.g. `-C "dana-laptop"`) so everyone can tell the keys apart at a glance. A key generated without `-C` shows as **(no comment)**.
|
||||
</Aside>
|
||||
|
||||
## Connect over SFTP with your key
|
||||
|
||||
Point your SFTP client at the private key file (the one *without* `.pub`):
|
||||
|
||||
```bash
|
||||
sftp -i ~/.ssh/id_ed25519 <username>@<your-server>.cloud-hosting.io
|
||||
```
|
||||
|
||||
Replace `<username>` with your WHP username and `<your-server>` with your server's hostname (find it on the **Dashboard** page under Server Information). Graphical clients — FileZilla, Cyberduck, WinSCP — all support key-based login too: point the client's "private key" field at `~/.ssh/id_ed25519` instead of filling in a password.
|
||||
|
||||
Your account password still works for SFTP exactly as before. Adding a key doesn't disable it.
|
||||
|
||||
## Supported key types
|
||||
|
||||
- **`ssh-ed25519`** — recommended. Generate with `ssh-keygen -t ed25519`.
|
||||
- `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`
|
||||
- `ssh-rsa`
|
||||
- `sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com` — for a FIDO/hardware security key (e.g. a YubiKey)
|
||||
|
||||
If a key is malformed, an unsupported type, or technically well-formed but not something the SFTP server can actually use, the panel rejects it with an explanation instead of adding it — so a key that's been accepted is a key that works.
|
||||
|
||||
## Remove a key
|
||||
|
||||
Find the key in the **Authorized Keys** table and click **Remove**. Confirm in the dialog — the fingerprint and comment are shown so you can be sure you're removing the right one. This takes effect immediately: anyone using that key loses SFTP access right away, but password login is unaffected.
|
||||
|
||||
There's no restriction on removing your last key. If you remove every key on the account, SFTP still works with your account password — keys are additive, not a replacement for it.
|
||||
|
||||
Common reasons to remove a key: someone leaves the team, or a laptop with a key on it is lost or retired.
|
||||
|
||||
<Aside type="note">
|
||||
Keys can also be added or removed directly in `~/.ssh/authorized_keys` over SFTP itself, and valid changes made that way show up here too. But changes made outside this page aren't recorded in the audit log, and anything in that file the panel doesn't recognize (an unsupported key type, a malformed line, a restricted `command=`/`from=` entry) is cleaned up automatically the next time this page loads. Comments and blank lines are always left alone.
|
||||
</Aside>
|
||||
|
||||
## If you manage this account on someone else's behalf
|
||||
|
||||
Root and support staff reach the same page for any customer account — either from the same **Security → SFTP SSH Keys** sidebar entry (which then shows an account selector), or via a **SFTP Keys** button on that customer's row on the **User Management** page. Everything above works the same way once an account is selected; the only difference is picking whose keys you're managing first.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"This key is already present."** That exact key (by fingerprint) is already on the account — check the table before adding it again.
|
||||
|
||||
**Key added, but SFTP still asks for a password.** Double-check you're pointing your client at the matching **private** key file (no `.pub`), and that the username in your connection matches the account the key was added to.
|
||||
|
||||
**A key I added over raw SFTP disappeared.** If it didn't meet this page's rules (unsupported type, malformed, or carried a restricted option), it was removed the next time the page loaded — add it here instead so it's validated and recorded properly.
|
||||
|
||||
## Related
|
||||
|
||||
- [Create a site](/whp/how-to/create-a-site/) — including where your files go over SFTP.
|
||||
|
||||
## Still stuck?
|
||||
|
||||
<Support />
|
||||
@@ -63,6 +63,7 @@ npx tsx tools/screenshots/capture-dns.ts
|
||||
| `capture-site-builder.ts` | Site Builder editor states | `WHP_USER` |
|
||||
| `capture-dns.ts` | Domains & DNS list, Add Domain modal, records editor, bulk toolbar | `WHP_USER` |
|
||||
| `capture-email.ts` | Email page "Mail Client Setup" section (autodiscovery DNS records) | `WHP_USER` |
|
||||
| `capture-quarantine.ts` | PMG spam quarantine portal (login, request-link dialog, held-mail list, message actions) — **not a WHP page**, targets the mail filtering gateway directly | `PMG_BASE` + `PMG_TICKET_URL` (a real quarantine ticket link, e.g. from a test mailbox's "Request Quarantine Link" email) instead of WHP_USER/PASS |
|
||||
|
||||
Each script carries its own `redact()` (text-node + input-value swaps) so fleet hostnames, IPs, and customer data become neutral placeholders while brand/demo domains stay visible. Copy the closest existing script when adding a new section — match its viewport (1440×900), `deviceScaleFactor: 2`, and **read-only** discipline (open modals and tick boxes for the shot, but never save/delete/submit).
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PMG quarantine capture — the customer self-service spam quarantine portal.
|
||||
*
|
||||
* Captures:
|
||||
* - email-quarantine-login.png Login screen showing the "Request Quarantine
|
||||
* Link" button next to Login.
|
||||
* - email-quarantine-request.png The "Request Quarantine Link" dialog with an
|
||||
* email address entered.
|
||||
* - email-quarantine-list.png The authenticated quarantine grid showing held
|
||||
* mail, redacted to generic example content.
|
||||
* - email-quarantine-actions.png A selected message with the Whitelist /
|
||||
* Blacklist / Deliver / Delete toolbar visible.
|
||||
*
|
||||
* Viewport-only (1440x900, deviceScaleFactor 2), redacted: the real server
|
||||
* hostname, the real test mailbox address, and the real (internal-test-looking)
|
||||
* sender/subject/body text are all swapped for generic customer-facing examples.
|
||||
*
|
||||
* Read-only: never actually delivers/whitelists/blacklists/deletes anything.
|
||||
*/
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/email');
|
||||
|
||||
const BASE = process.env.PMG_BASE || 'https://pmg01.cloud-hosting.io:8006';
|
||||
const TICKET_URL = process.env.PMG_TICKET_URL; // full quarantine?ticket=... URL
|
||||
|
||||
/** Swap real fleet/test identifiers for believable generic customer-facing text. */
|
||||
async function redact(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const swaps: [RegExp, string][] = [
|
||||
[/pmg01\.cloud-hosting\.io/gi, 'mail.yourdomain.com'],
|
||||
[/claude@darksideofperfection\.com/gi, 'you@yourdomain.com'],
|
||||
[/pmg-pilot-tester@anthropic\.com/gi, 'prizes@totally-legit-sweepstakes.example'],
|
||||
[/SPAM: PMG pilot - spam detection test \(GTUBE\) \(retry\)/g, "SPAM: You've WON a prize!!!"],
|
||||
[/SPAM: PMG pilot - clean delivery test \(retry\)/g, 'SPAM: Limited time offer - act now'],
|
||||
[/This is a clean end-to-end delivery test, retried after greylist window\./g,
|
||||
'Congratulations! You have been selected to receive an exclusive reward. Click below to claim yours before this offer expires.'],
|
||||
[/GTUBE retry test\./g, "You've been selected for an exclusive reward!"],
|
||||
[/XJS\*C4JDBQADN1\.NSBN3\*2IDNEN\*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL\*C\.34X/g,
|
||||
'Click here to claim your prize before this limited-time offer expires: http://totally-legit-sweepstakes.example/claim'],
|
||||
[/\b1010\b/g, '8.4'],
|
||||
];
|
||||
const docs: Document[] = [document];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try {
|
||||
const doc = (f as HTMLIFrameElement).contentDocument;
|
||||
if (doc) docs.push(doc);
|
||||
} catch { /* cross-origin, skip */ }
|
||||
}
|
||||
for (const root of docs) {
|
||||
const walker = root.createTreeWalker(root.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!TICKET_URL) throw new Error('missing env: PMG_TICKET_URL');
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
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 {
|
||||
// --- Shot 1: login screen, showing the Request Quarantine Link button ---
|
||||
await page.goto(`${BASE}/quarantine`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('text=Request Quarantine Link', { state: 'visible' });
|
||||
await page.waitForTimeout(300);
|
||||
await redact(page);
|
||||
let p = resolve(OUT_DIR, 'email-quarantine-login.png');
|
||||
await page.locator('.x-window').first().screenshot({ path: p });
|
||||
console.log(`captured email-quarantine-login -> ${p}`);
|
||||
|
||||
// --- Shot 2: the Request Quarantine Link dialog, filled in ---
|
||||
await page.getByRole('button', { name: 'Request Quarantine Link' }).click();
|
||||
await page.getByRole('textbox', { name: 'Your E-Mail:' }).fill('you@yourdomain.com');
|
||||
await page.waitForTimeout(200);
|
||||
p = resolve(OUT_DIR, 'email-quarantine-request.png');
|
||||
await page.locator('.x-window').last().screenshot({ path: p });
|
||||
console.log(`captured email-quarantine-request -> ${p}`);
|
||||
|
||||
// --- Shot 3 & 4: authenticated quarantine view, via the real ticket link ---
|
||||
await page.goto(TICKET_URL, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('grid[aria-label="Spam Quarantine"], .x-grid', { state: 'visible' }).catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
await redact(page);
|
||||
p = resolve(OUT_DIR, 'email-quarantine-list.png');
|
||||
await page.screenshot({ path: p });
|
||||
console.log(`captured email-quarantine-list -> ${p}`);
|
||||
|
||||
// Select the first row to reveal the action toolbar + message preview.
|
||||
const firstRow = page.locator('.x-grid-row').first();
|
||||
await firstRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
await redact(page);
|
||||
p = resolve(OUT_DIR, 'email-quarantine-actions.png');
|
||||
await page.screenshot({ path: p });
|
||||
console.log(`captured email-quarantine-actions -> ${p}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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); });
|
||||