Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/self-hosted/hosting/origin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ in practice — **re-copy any change made there back into this directory.**
reloads nginx if something changed. A vhost that fails `nginx -t` is quarantined as
`.broken` rather than wedging every later reload.

## A custom domain also needs a Turnstile hostname

The sync does NOT do this, and nothing else will notice if it is skipped.

The newsletter signup form on a tenant blog renders a Cloudflare Turnstile widget, and the
relay refuses an anonymous subscribe without a valid token. A Turnstile sitekey is bound to
a hostname list, and adding a hostname covers that hostname **and all of its subdomains**,
so every `*.blogs.ecency.com` tenant is already covered by the `ecency.com` entry and needs
nothing. A custom domain on its own apex is not.

So when a custom domain is attached, add it to the sitekey's domain list as well:

```bash
# read the current list first; the widget update is a PUT of the whole object
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT/challenges/widgets/$SITEKEY"

curl -X PUT -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT/challenges/widgets/$SITEKEY" \
--data '{"name":"ecency.com","mode":"managed","domains":["ecency.com","<the new domain>"]}'
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
```

Skipping it leaves that tenant's signup form with a widget that will not solve and a submit
button that never enables. It fails visibly on the blog and invisibly to us, which is why
it belongs in this list rather than in someone's memory.

## origin-ips (not in git)

The DNS check above needs this origin's own addresses. They are read from an `origin-ips`
Expand Down
2 changes: 2 additions & 0 deletions apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export type TranslationKey =
| 'newsletterCheckInbox'
| 'newsletterUseAnotherAddress'
| 'newsletterError'
| 'newsletterCaptchaFailed'
| 'panel_configuration_instance_configuration_features_newsletter_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_description';
Expand Down Expand Up @@ -351,6 +352,7 @@ export const translations: { en: Translations } & Record<
newsletterCheckInbox: 'Almost there: confirm from the email we just sent.',
newsletterUseAnotherAddress: 'Use a different address',
newsletterError: 'Could not subscribe right now. Please try again.',
newsletterCaptchaFailed: 'Verification could not load. Please reload the page to subscribe.',
loading: "Loading...",
hivesigner_login_failed: 'Sign in could not be completed. Please try again.',
loadingPost: "Loading post...",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@ import {
} from '../../../core/configuration-loader';
import { NewsletterSignup } from './newsletter-signup';

/**
* The Turnstile widget, mocked. The real one appends a Cloudflare <script> that jsdom
* never executes, so the token would never arrive and every submit here would sit behind
* a permanently disabled button. The mock renders nothing and hands the test the
* callbacks, which is the whole contract the form depends on.
*
* It renders NO element on purpose: `resetControl()` below selects the first
* `button[type="button"]`, and a mock button would quietly steal that selector from the
* use-another-address control it was written for.
*/
const captcha = vi.hoisted(() => ({
verify: null as null | ((token: string) => void),
resets: 0
}));

vi.mock('../../shared/turnstile', () => ({
Turnstile: ({
onVerify,
ref
}: {
onVerify: (token: string) => void;
ref?: { current: { reset: () => void } | null };
}) => {
captcha.verify = onVerify;
if (ref) ref.current = { reset: () => { captcha.resets += 1; } };
return null;
}
}));

const CAPTCHA_TOKEN = 'turnstile-test-token';

/** Solve the challenge, the way a reader does before the button becomes usable. */
async function solveCaptcha(): Promise<void> {
await act(async () => {
captcha.verify?.(CAPTCHA_TOKEN);
});
}

/**
* The component half of the signup form (vision-web#1537). The pure rules live
* in newsletter-signup-target.test.ts; this covers what only a rendered
Expand Down Expand Up @@ -142,6 +180,11 @@ async function typeInto(
*/
async function submitForm(): Promise<void> {
const form = container.querySelector('form');
// The relay refuses an anonymous subscribe without a token and the button stays
// disabled until one exists, so an unsolved challenge is not a state a reader can
// submit from. Tests that care about the gate itself solve explicitly and assert
// before calling this.
if (form && captcha.verify) await solveCaptcha();
await act(async () => {
form?.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
Expand All @@ -168,6 +211,8 @@ describe('NewsletterSignup', () => {
InstanceConfigManager.updateConfig(
structuredClone(MANAGED_BLOG) as InstanceConfig,
);
captcha.verify = null;
captcha.resets = 0;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
Expand Down Expand Up @@ -278,7 +323,7 @@ describe('NewsletterSignup', () => {
email: 'reader@example.com',
type: 'creator',
target: 'alice',
targetLabel: 'Alice Writes',
captchaToken: CAPTCHA_TOKEN,
cadence: 'monthly',
source: 'self-hosted-blog',
});
Expand Down Expand Up @@ -306,11 +351,62 @@ describe('NewsletterSignup', () => {
'Could not subscribe right now. Please try again.',
);
expect(politeRegion()?.textContent).toBe('');
// Still submittable: the reader can retry without losing what they typed.
// Still there, and the address is not lost: the reader retries without retyping.
expect(form()).not.toBeNull();
expect(submitButton()?.disabled).toBe(false);
expect(submitButton()?.getAttribute('aria-busy')).toBe('false');
expect(emailInput()?.value).toBe('reader@example.com');
expect(submitButton()?.getAttribute('aria-busy')).toBe('false');
// But the challenge was spent on the attempt that failed, so the button waits for a
// fresh one. Re-submitting with the used token would fail again at the relay, and the
// reader would read the same generic error with nothing to act on.
expect(captcha.resets).toBe(1);
expect(submitButton()?.disabled).toBe(true);
await solveCaptcha();
expect(submitButton()?.disabled).toBe(false);
});

it('will not submit until the challenge is solved, and never posts without a token', async () => {
// A blog reader has no Ecency session, so the relay treats every submit here as
// anonymous and refuses one carrying no token. The button is the visible half of
// that; the handler's own guard is the half that matters, because a form can still
// be submitted from the keyboard while its button is disabled.
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({ ok: true }));
vi.stubGlobal('fetch', fetchMock);

await render();
await typeInto(emailInput(), 'reader@example.com');
expect(submitButton()?.disabled).toBe(true);

// Submitting anyway, the way a keyboard reader can: nothing leaves.
const el = container.querySelector('form');
await act(async () => {
el?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
expect(fetchMock).not.toHaveBeenCalled();
expect(errorRegion()?.textContent).toBe('');

await solveCaptcha();
expect(submitButton()?.disabled).toBe(false);
await submitForm();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).captchaToken).toBe(
CAPTCHA_TOKEN,
);
});

it('asks for a fresh challenge for the next address', async () => {
// The token is single use, so the way back from the confirmation (#1546) has to
// re-challenge or the second address is submitted with a spent one.
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true })));
await render();
await typeInto(emailInput(), 'reader@example.com');
await submitForm();
expect(resetControl()).not.toBeNull();

await act(async () => {
resetControl()?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(captcha.resets).toBe(1);
expect(submitButton()?.disabled).toBe(true);
});

it('shows the same error when the request never completes', async () => {
Expand Down Expand Up @@ -369,7 +465,7 @@ describe('NewsletterSignup', () => {
expect(JSON.parse(init.body as string)).toMatchObject({
type: 'community',
target: 'hive-125125',
targetLabel: 'Alice Writes',
captchaToken: CAPTCHA_TOKEN,
cadence: 'weekly',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type FormEvent, type ReactElement, useEffect, useRef, useState } from '
import { InstanceConfigManager } from '../../../core/configuration-loader';
import { t } from '../../../core/i18n';
import { LiveRegion } from '../../shared/live-region';
import { Turnstile, type TurnstileHandle } from '../../shared/turnstile';
import { newsletterSignupTarget, newsletterSubscribeBody } from '../utils/newsletter-signup-target';

/**
Expand Down Expand Up @@ -93,6 +94,9 @@ export function NewsletterSignup({
* replaced by the form again. Never on first render, or the page would hand
* focus to a sidebar form the moment it loads.
*/
const [captchaToken, setCaptchaToken] = useState('');
const turnstileRef = useRef<TurnstileHandle>(null);

const restoreFocus = useRef(false);
useEffect(() => {
if (!restoreFocus.current) return;
Expand All @@ -117,20 +121,30 @@ export function NewsletterSignup({

const submit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
if (state === 'busy' || !email.trim()) return;
if (state === 'busy' || !email.trim() || !captchaToken) return;
setState('busy');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newsletterSubscribeBody(target, email, cadence)),
body: JSON.stringify(newsletterSubscribeBody(target, email, cadence, captchaToken)),
});
if (!mounted.current) return;
// Only success swaps the form out, so only success has focus to rescue.
restoreFocus.current = res.ok;
setState(res.ok ? 'done' : 'error');
// The token is single use whatever the outcome, and a reader who retries with a
// spent one gets the same generic error twice with nothing to act on.
if (!res.ok) {
setCaptchaToken('');
turnstileRef.current?.reset();
}
} catch {
if (mounted.current) setState('error');
if (mounted.current) {
setState('error');
setCaptchaToken('');
turnstileRef.current?.reset();
}
}
};

Expand All @@ -144,6 +158,8 @@ export function NewsletterSignup({
restoreFocus.current = true;
setEmail('');
setState('idle');
setCaptchaToken('');
turnstileRef.current?.reset();
};

return (
Expand Down Expand Up @@ -184,6 +200,17 @@ export function NewsletterSignup({
aria-label={t('newsletterEmail')}
className="input-theme w-full text-sm px-2 py-1.5 rounded"
/>
{/* Anonymous by definition here: a blog reader has no Ecency session, so the
relay always wants a token. The submit stays disabled until one exists,
which is also what happens when the script is blocked -- the widget then
says so rather than failing at submit with a generic error. */}
<Turnstile
ref={turnstileRef}
action="newsletter-subscribe"
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken('')}
onError={() => setCaptchaToken('')}
/>
<div className="flex gap-2">
<select
value={cadence}
Expand All @@ -196,7 +223,7 @@ export function NewsletterSignup({
</select>
<button
type="submit"
disabled={state === 'busy'}
disabled={state === 'busy' || !captchaToken}
aria-busy={state === 'busy'}
className="btn-theme-primary text-sm px-3 py-1.5 rounded disabled:opacity-60"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ describe('newsletterSignupTarget', () => {

it('builds the exact relay body, source self-hosted-blog', () => {
const t = newsletterSignupTarget(managedBlog)!;
expect(newsletterSubscribeBody(t, ' reader@example.com ', 'monthly')).toEqual({
expect(newsletterSubscribeBody(t, ' reader@example.com ', 'monthly', 'tok')).toEqual({
email: 'reader@example.com',
type: 'creator',
target: 'alice',
targetLabel: 'Alice Writes',
cadence: 'monthly',
source: 'self-hosted-blog',
// The site title is NOT sent any more: it used to let a caller write part of a
// sentence in mail our domain sends to an address they chose. The service derives
// the label from the target now. targetLabel still exists on the TARGET above,
// for the copy this app renders itself.
captchaToken: 'tok',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,37 @@ export interface NewsletterSubscribeBody {
email: string;
type: 'creator' | 'community';
target: string;
targetLabel: string;
cadence: 'weekly' | 'monthly';
source: 'self-hosted-blog';
/**
* Cloudflare Turnstile token. The relay requires one from every caller without an
* account, and a reader on a blog never has one, so it is required here in practice.
*/
captchaToken: string;
}

/** The body the form posts, exactly as the relay expects it. */
export function newsletterSubscribeBody(t: NewsletterSignupTarget, email: string, cadence: 'weekly' | 'monthly'): NewsletterSubscribeBody {
/**
* The body the form posts, exactly as the relay expects it.
*
* `targetLabel` is deliberately NOT sent any more. It used to carry the site title into
* the confirmation email, which meant a caller chose part of a sentence in mail our domain
* sends to an address they picked; the service derives the label from the target itself
* now. `NewsletterSignupTarget.targetLabel` still exists and is still used, for the copy
* this app renders itself.
*/
export function newsletterSubscribeBody(
t: NewsletterSignupTarget,
email: string,
cadence: 'weekly' | 'monthly',
captchaToken: string
): NewsletterSubscribeBody {
return {
email: email.trim(),
type: t.type,
target: t.target,
targetLabel: t.targetLabel,
cadence,
source: 'self-hosted-blog',
captchaToken,
};
}

Expand Down
Loading
Loading