feat(bots-discord): land hCaptcha challenge handling for QR-login with sentinel-prefixed bridge protocol and Dawn-themed widget UI
This commit is contained in:
parent
295dfbb796
commit
1d64275bae
8 changed files with 680 additions and 34 deletions
|
|
@ -144,6 +144,10 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
||||||
return t('auth-error.login-failed', { reason: err.reason ?? '' });
|
return t('auth-error.login-failed', { reason: err.reason ?? '' });
|
||||||
case 'captcha_required':
|
case 'captcha_required':
|
||||||
return t('auth-error.captcha-required');
|
return t('auth-error.captcha-required');
|
||||||
|
case 'captcha_send_failed':
|
||||||
|
return t('auth-error.captcha-send-failed');
|
||||||
|
case 'captcha_expired':
|
||||||
|
return t('auth-error.captcha-expired');
|
||||||
case 'login_websocket_failed':
|
case 'login_websocket_failed':
|
||||||
return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
|
return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
|
||||||
case 'connect_after_login_failed':
|
case 'connect_after_login_failed':
|
||||||
|
|
@ -166,6 +170,7 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
||||||
// failure of the login attempt and gets red treatment.
|
// failure of the login attempt and gets red treatment.
|
||||||
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
|
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
|
||||||
if (err.kind === 'captcha_required') return 'warn';
|
if (err.kind === 'captcha_required') return 'warn';
|
||||||
|
if (err.kind === 'captcha_expired') return 'warn';
|
||||||
if (err.kind === 'already_logged_in') return 'warn';
|
if (err.kind === 'already_logged_in') return 'warn';
|
||||||
return 'error';
|
return 'error';
|
||||||
};
|
};
|
||||||
|
|
@ -319,6 +324,294 @@ const QrPanel = ({ state, t, onCancel }: QrPanelProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Captcha panel — Vojo-patched bridge surfaces an hCaptcha challenge via
|
||||||
|
// the `VOJO-CAPTCHA-CHALLENGE-V1` notice. The panel lazy-loads hCaptcha's
|
||||||
|
// own JS, renders the challenge widget into our container with the supplied
|
||||||
|
// sitekey + rqdata (the latter is REQUIRED for hCaptcha-enterprise — Discord
|
||||||
|
// uses the enterprise tier), and on solve hands the token back to the App
|
||||||
|
// which sends `login-captcha <token>` to the bridge.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Canonical hCaptcha bootstrapping for explicit render: combine
|
||||||
|
// `?render=explicit` (don't auto-render any data-sitekey div) with
|
||||||
|
// `?onload=fnname` (call our resolver when the SDK is fully initialized).
|
||||||
|
// docs.hcaptcha.com/configuration recommends this over polling
|
||||||
|
// `window.hcaptcha` because (a) the global appears before its methods are
|
||||||
|
// safe to call in some SDK versions, (b) onload fires once and is cheaper
|
||||||
|
// than a 50ms tick. We still keep a wall-clock timeout as a backstop —
|
||||||
|
// covers ad-block / CSP / third-party-cookie blocks where the script
|
||||||
|
// itself fails to load.
|
||||||
|
const HCAPTCHA_ONLOAD_FN = '__vojoHCaptchaOnload';
|
||||||
|
const HCAPTCHA_API_URL = `https://js.hcaptcha.com/1/api.js?render=explicit&onload=${HCAPTCHA_ONLOAD_FN}`;
|
||||||
|
const HCAPTCHA_LOAD_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
// hCaptcha render() does NOT accept rqdata as a config field — for
|
||||||
|
// hCaptcha-enterprise (which Discord uses) rqdata must be applied via
|
||||||
|
// `setData(widgetId, { rqdata })` AFTER `render()`. Passing rqdata to
|
||||||
|
// render is silently ignored, the resulting token isn't bound to the
|
||||||
|
// challenge, and Discord rejects with `invalid-response` /
|
||||||
|
// `sitekey-secret-mismatch`. Sources:
|
||||||
|
// https://github.com/hCaptcha/vue-hcaptcha (search `setData(`)
|
||||||
|
// https://github.com/hCaptcha/react-hcaptcha/issues/190
|
||||||
|
type HCaptchaConfig = {
|
||||||
|
sitekey: string;
|
||||||
|
callback?: (token: string) => void;
|
||||||
|
'expired-callback'?: () => void;
|
||||||
|
'error-callback'?: () => void;
|
||||||
|
size?: 'normal' | 'compact' | 'invisible';
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
};
|
||||||
|
type HCaptchaApi = {
|
||||||
|
render: (container: HTMLElement, config: HCaptchaConfig) => string;
|
||||||
|
setData: (widgetId: string, data: { rqdata: string }) => void;
|
||||||
|
reset: (id?: string) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
};
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
hcaptcha?: HCaptchaApi;
|
||||||
|
[HCAPTCHA_ONLOAD_FN]?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hcaptchaScriptPromise: Promise<HCaptchaApi> | null = null;
|
||||||
|
|
||||||
|
const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
||||||
|
if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
|
||||||
|
// Already initialised (e.g. earlier mount of the captcha panel) — skip
|
||||||
|
// the script dance entirely.
|
||||||
|
if (window.hcaptcha) return Promise.resolve(window.hcaptcha);
|
||||||
|
if (hcaptchaScriptPromise) return hcaptchaScriptPromise;
|
||||||
|
hcaptchaScriptPromise = new Promise<HCaptchaApi>((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(
|
||||||
|
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||||
|
) as HTMLScriptElement | null;
|
||||||
|
|
||||||
|
let timeoutHandle: number | undefined;
|
||||||
|
let settled = false;
|
||||||
|
const settle = (action: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle);
|
||||||
|
// Drop the global — leaves `window` clean if no other consumer
|
||||||
|
// is hooked. Future `loadHCaptcha()` calls hit the early-return
|
||||||
|
// `if (window.hcaptcha)` and skip this whole path.
|
||||||
|
delete window[HCAPTCHA_ONLOAD_FN];
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
// `?onload=` calls our global once the SDK finishes initialising.
|
||||||
|
// We register BEFORE appending the script so the listener is in
|
||||||
|
// place if the script is already cached and runs synchronously.
|
||||||
|
window[HCAPTCHA_ONLOAD_FN] = () => {
|
||||||
|
if (window.hcaptcha) {
|
||||||
|
settle(() => resolve(window.hcaptcha as HCaptchaApi));
|
||||||
|
} else {
|
||||||
|
// Defence-in-depth: onload fired but global missing. Treat as
|
||||||
|
// a load failure and let the panel show the error UI.
|
||||||
|
settle(() => {
|
||||||
|
hcaptchaScriptPromise = null;
|
||||||
|
reject(new Error('hcaptcha onload fired without window.hcaptcha'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = HCAPTCHA_API_URL;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.addEventListener('error', () =>
|
||||||
|
settle(() => {
|
||||||
|
hcaptchaScriptPromise = null;
|
||||||
|
reject(new Error('hcaptcha script failed to load'));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else if (window.hcaptcha) {
|
||||||
|
// A previous mount already loaded the SDK fully; resolve immediately.
|
||||||
|
settle(() => resolve(window.hcaptcha as HCaptchaApi));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise: existing script is mid-load — our onload hook will fire
|
||||||
|
// when it finishes.
|
||||||
|
|
||||||
|
timeoutHandle = window.setTimeout(() => {
|
||||||
|
settle(() => {
|
||||||
|
hcaptchaScriptPromise = null;
|
||||||
|
reject(new Error('hcaptcha script load timed out'));
|
||||||
|
});
|
||||||
|
}, HCAPTCHA_LOAD_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
return hcaptchaScriptPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaptchaPanelProps = {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_captcha_solve';
|
||||||
|
service: string;
|
||||||
|
sitekey: string;
|
||||||
|
rqdata: string;
|
||||||
|
rqtoken: string;
|
||||||
|
};
|
||||||
|
t: T;
|
||||||
|
onSolved: (token: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
// Fired when the hCaptcha challenge expires (either Discord's
|
||||||
|
// server-side rqtoken TTL or our local 90s timer beats the user). The
|
||||||
|
// App rolls back to disconnected with a localized warn so the user
|
||||||
|
// retries login-qr instead of trying to solve a dead challenge.
|
||||||
|
onExpired: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match the bridge's CAPTCHA_HYDRATE_FRESHNESS_MS — Discord invalidates
|
||||||
|
// rqtoken around the 2-minute mark; we expire the UI a little earlier so
|
||||||
|
// the user retries fresh instead of getting a server-side rejection.
|
||||||
|
const CAPTCHA_EXPIRY_MS = 90 * 1000;
|
||||||
|
|
||||||
|
const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<boolean>(false);
|
||||||
|
// Set true once we've finished render + setData — gate the solve
|
||||||
|
// callback on this so a hCaptcha auto-pass (low-friction users) or a
|
||||||
|
// fast click before setData lands can't ship a token unbound to rqdata.
|
||||||
|
// Discord rejects unbound tokens with `invalid-response`, burning the
|
||||||
|
// rqtoken and forcing a fresh challenge.
|
||||||
|
const dataReadyRef = useRef<boolean>(false);
|
||||||
|
// Idempotency latch on the solve callback — guards against hCaptcha
|
||||||
|
// firing the callback twice (observed when users double-click "I am
|
||||||
|
// human" or when low-risk users get the silent auto-pass that some
|
||||||
|
// SDK versions deliver via two events). Without this, two
|
||||||
|
// login-captcha commands ship to the bridge with the same single-use
|
||||||
|
// token and the second one tears down the session via
|
||||||
|
// `response-already-used`.
|
||||||
|
const solvedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
dataReadyRef.current = false;
|
||||||
|
solvedRef.current = false;
|
||||||
|
setLoadError(false);
|
||||||
|
|
||||||
|
// 90-second client-side expiry timer. Drives the localized
|
||||||
|
// "captcha_expired" error rather than letting the user solve a
|
||||||
|
// dead rqtoken and watch the bridge return a generic Discord
|
||||||
|
// failure.
|
||||||
|
const expiryTimer = window.setTimeout(() => {
|
||||||
|
if (!cancelled && !solvedRef.current) onExpired();
|
||||||
|
}, CAPTCHA_EXPIRY_MS);
|
||||||
|
|
||||||
|
// Pass the host theme into hCaptcha so the chrome inside the iframe
|
||||||
|
// matches Vojo's surface — the host writes `data-theme` on
|
||||||
|
// <html> at boot from the `?theme=` URL param. Without this the
|
||||||
|
// hCaptcha widget paints its default light variant inside our
|
||||||
|
// Dawn-dark card and reads as a foreign white box.
|
||||||
|
const hcaptchaTheme: 'light' | 'dark' =
|
||||||
|
document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
loadHCaptcha()
|
||||||
|
.then((api) => {
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
const id = api.render(containerRef.current, {
|
||||||
|
sitekey: state.sitekey,
|
||||||
|
theme: hcaptchaTheme,
|
||||||
|
size: 'normal',
|
||||||
|
callback: (token) => {
|
||||||
|
// Gate on dataReadyRef AND solvedRef. dataReadyRef rejects a
|
||||||
|
// token before setData bound rqdata; solvedRef rejects
|
||||||
|
// duplicate callbacks for the same challenge.
|
||||||
|
if (cancelled || !dataReadyRef.current || solvedRef.current) return;
|
||||||
|
solvedRef.current = true;
|
||||||
|
onSolved(token);
|
||||||
|
},
|
||||||
|
'expired-callback': () => {
|
||||||
|
if (cancelled || solvedRef.current) return;
|
||||||
|
onExpired();
|
||||||
|
},
|
||||||
|
'error-callback': () => {
|
||||||
|
if (!cancelled) setLoadError(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Race guard: cancel may have flipped between the await and
|
||||||
|
// render. If so, the iframe is mounted into a detached node — clean
|
||||||
|
// up immediately rather than leaking the widget id.
|
||||||
|
if (cancelled) {
|
||||||
|
try {
|
||||||
|
api.remove(id);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widgetIdRef.current = id;
|
||||||
|
// Bind the solve to Discord's challenge nonce. hCaptcha-enterprise
|
||||||
|
// (which Discord uses) requires setData AFTER render — passing
|
||||||
|
// rqdata into render() is silently ignored.
|
||||||
|
try {
|
||||||
|
api.setData(id, { rqdata: state.rqdata });
|
||||||
|
dataReadyRef.current = true;
|
||||||
|
} catch (err) {
|
||||||
|
// setData failed (unknown widget id, SDK bug, etc.) — tear the
|
||||||
|
// widget down so a stray callback can't ship an unbound token.
|
||||||
|
// Surface the load-error UI; the user can retry login-qr.
|
||||||
|
try {
|
||||||
|
api.remove(id);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
if (!cancelled) setLoadError(true);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[captcha] hcaptcha.setData failed', err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(expiryTimer);
|
||||||
|
// Best-effort cleanup. hCaptcha's own remove() is idempotent and
|
||||||
|
// tolerates missing widget ids; if the iframe was never rendered
|
||||||
|
// (load failure) the catch is harmless.
|
||||||
|
try {
|
||||||
|
if (window.hcaptcha && widgetIdRef.current) {
|
||||||
|
window.hcaptcha.remove(widgetIdRef.current);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
};
|
||||||
|
// rqtoken is the per-challenge nonce — chained captchas reuse the same
|
||||||
|
// sitekey/rqdata-shape but bring a new rqtoken; we want a remount on
|
||||||
|
// any of these for safety. The whole CaptchaSolveState normally
|
||||||
|
// replaces wholesale, but listing all three guards against future
|
||||||
|
// partial mutations.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.sitekey, state.rqdata, state.rqtoken]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="auth-card auth-card-captcha">
|
||||||
|
<div class="auth-card-title">{t('auth-card.captcha.title')}</div>
|
||||||
|
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||||
|
<div class="auth-card-captcha-frame">
|
||||||
|
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||||
|
{loadError ? (
|
||||||
|
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div class="auth-card-row">
|
||||||
|
<button type="button" class="btn-text" onClick={onCancel}>
|
||||||
|
{t('auth-card.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// About card + modal
|
// About card + modal
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
@ -628,6 +921,12 @@ export function App({ bootstrap, api }: Props) {
|
||||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
appendedAnyHistory = true;
|
appendedAnyHistory = true;
|
||||||
}
|
}
|
||||||
|
} else if (parsed.kind === 'captcha_challenge') {
|
||||||
|
// Captcha challenge body carries hCaptcha rqdata/rqtoken
|
||||||
|
// — short-lived but credential-bearing. Diag-only on
|
||||||
|
// hydrate too; the live path treats it identically.
|
||||||
|
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||||
|
appendedAnyHistory = true;
|
||||||
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||||
// m.text / m.notice — body is safe to replay verbatim,
|
// m.text / m.notice — body is safe to replay verbatim,
|
||||||
// BUT we still scrub any login-URL-shaped substring as
|
// BUT we still scrub any login-URL-shaped substring as
|
||||||
|
|
@ -696,6 +995,12 @@ export function App({ bootstrap, api }: Props) {
|
||||||
) {
|
) {
|
||||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
}
|
}
|
||||||
|
} else if (event.kind === 'captcha_challenge') {
|
||||||
|
// The raw body carries Discord's hCaptcha sitekey + rqdata + rqtoken
|
||||||
|
// — short-lived but credential-bearing. Don't echo into the
|
||||||
|
// transcript DOM (where screenshots / accessibility tools could
|
||||||
|
// leak them). Diag-only display.
|
||||||
|
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||||
const body = ev.content.body ?? '';
|
const body = ev.content.body ?? '';
|
||||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||||||
|
|
@ -759,6 +1064,27 @@ export function App({ bootstrap, api }: Props) {
|
||||||
[append, api, t]
|
[append, api, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Specialised send for hCaptcha tokens. The token is a single-use
|
||||||
|
// credential — we send it to the bridge but redact the body in the
|
||||||
|
// user-visible transcript so it doesn't sit in DOM (and screenshots /
|
||||||
|
// accessibility tools can't see it). The error path keeps the same
|
||||||
|
// shape as `sendBare` so the caller can `try { await … } catch { roll back }`.
|
||||||
|
const sendCaptchaToken = useCallback(
|
||||||
|
async (token: string): Promise<void> => {
|
||||||
|
append({ kind: 'from-user', text: '→ login-captcha [redacted]' });
|
||||||
|
try {
|
||||||
|
await api.sendCommand(`login-captcha ${token}`);
|
||||||
|
} catch (err) {
|
||||||
|
append({
|
||||||
|
kind: 'error',
|
||||||
|
text: t('diag.send-failed', { message: (err as Error).message }),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, api, t]
|
||||||
|
);
|
||||||
|
|
||||||
// In-flight guard against double-tap. The button is on the disconnected
|
// In-flight guard against double-tap. The button is on the disconnected
|
||||||
// screen which unmounts as soon as state advances, BUT a rapid second
|
// screen which unmounts as soon as state advances, BUT a rapid second
|
||||||
// click can fire in the microtask window between dispatch and the next
|
// click can fire in the microtask window between dispatch and the next
|
||||||
|
|
@ -788,6 +1114,35 @@ export function App({ bootstrap, api }: Props) {
|
||||||
dispatch({ kind: 'cancel_pending' });
|
dispatch({ kind: 'cancel_pending' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Captcha solve callback — fires from hCaptcha's own iframe. We dispatch
|
||||||
|
// `submit_captcha_token` first (transition to qr_verifying immediately so
|
||||||
|
// the user sees a spinner), then send `login-captcha <token>` to the
|
||||||
|
// bridge. The bridge re-fires Discord's RemoteAuthLogin with the
|
||||||
|
// X-Captcha-Key header attached and replies via the live event stream.
|
||||||
|
const onCaptchaSolved = useCallback(
|
||||||
|
async (token: string) => {
|
||||||
|
dispatch({ kind: 'submit_captcha_token' });
|
||||||
|
try {
|
||||||
|
await sendCaptchaToken(token);
|
||||||
|
} catch {
|
||||||
|
// Couldn't deliver to the bridge — roll the optimistic
|
||||||
|
// qr_verifying back to disconnected with a localized error.
|
||||||
|
// Don't auto-fall-through to ping: the failure is local
|
||||||
|
// (transport), not a bridge state mismatch, and a stale ping
|
||||||
|
// would mask it.
|
||||||
|
dispatch({ kind: 'captcha_send_failed' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sendCaptchaToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
// hCaptcha challenge expired (server-side rqtoken TTL or 90s local
|
||||||
|
// timer). Tell the user with a localized warn rather than letting them
|
||||||
|
// solve a dead challenge.
|
||||||
|
const onCaptchaExpired = useCallback(() => {
|
||||||
|
dispatch({ kind: 'captcha_expired' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onClickRefresh = useCallback(async () => {
|
const onClickRefresh = useCallback(async () => {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
|
@ -945,6 +1300,18 @@ export function App({ bootstrap, api }: Props) {
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'awaiting_captcha_solve' ? (
|
||||||
|
<section class="section">
|
||||||
|
<CaptchaPanel
|
||||||
|
state={state}
|
||||||
|
t={t}
|
||||||
|
onSolved={onCaptchaSolved}
|
||||||
|
onCancel={onClickCancel}
|
||||||
|
onExpired={onCaptchaExpired}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{state.kind === 'qr_verifying' ? (
|
{state.kind === 'qr_verifying' ? (
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<StatusRow
|
<StatusRow
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,21 @@ const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection
|
||||||
// fires `ping` after to pick up the snowflake.
|
// fires `ping` after to pick up the snowflake.
|
||||||
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
||||||
|
|
||||||
// login-qr CAPTCHA path — commands.go:fnLoginQR (l.207-209). The bridge
|
// login-qr CAPTCHA path — Vojo-patched bridge sends a single-line
|
||||||
// appends «CAPTCHAs are currently not supported - use token login instead»
|
// `VOJO-CAPTCHA-CHALLENGE-V1 {json}` m.notice carrying the hCaptcha
|
||||||
// to the standard `Error logging in: %v` reply. Detect the suffix
|
// sitekey, session_id, rqdata, rqtoken. The sentinel is markdown-inert by
|
||||||
// independently of the leading error verb so we surface a useful hint
|
// design (no `_`, `*`, `` ` ``, `[`, `<` characters): even if a future
|
||||||
// rather than a raw stack-trace tail.
|
// patch routes the bridge reply through goldmark again, the prefix
|
||||||
|
// survives intact. The bridge currently sends the notice via
|
||||||
|
// SendMessageEvent directly to bypass the framework's markdown round-trip.
|
||||||
|
// See bridge `commands_captcha.go` for the producer side.
|
||||||
|
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||||
|
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||||
|
|
||||||
|
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||||
|
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||||
|
// instead». Kept so a deployment running unpatched bridge still produces a
|
||||||
|
// useful hint rather than a generic Go-error tail.
|
||||||
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
|
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
|
||||||
|
|
||||||
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
|
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
|
||||||
|
|
@ -127,6 +137,27 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login lifecycle.
|
// Login lifecycle.
|
||||||
|
// Vojo-patched bridge: structured hCaptcha challenge wins over every
|
||||||
|
// legacy regex — checked first so the JSON payload survives.
|
||||||
|
if (body.startsWith(CAPTCHA_CHALLENGE_PREFIX)) {
|
||||||
|
const match = CAPTCHA_CHALLENGE_RE.exec(body);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||||
|
const service = typeof payload.service === 'string' ? payload.service : '';
|
||||||
|
const sitekey = typeof payload.sitekey === 'string' ? payload.sitekey : '';
|
||||||
|
const sessionId = typeof payload.session_id === 'string' ? payload.session_id : '';
|
||||||
|
const rqdata = typeof payload.rqdata === 'string' ? payload.rqdata : '';
|
||||||
|
const rqtoken = typeof payload.rqtoken === 'string' ? payload.rqtoken : '';
|
||||||
|
if (sitekey && rqtoken) {
|
||||||
|
return { kind: 'captcha_challenge', service, sitekey, sessionId, rqdata, rqtoken };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through — malformed payload is treated as unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
}
|
||||||
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||||
|
|
||||||
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||||
|
|
@ -313,13 +344,33 @@ function runSanityChecks(): void {
|
||||||
'Error logging in: rate limited 429',
|
'Error logging in: rate limited 429',
|
||||||
{ kind: 'login_failed', reason: 'rate limited 429' },
|
{ kind: 'login_failed', reason: 'rate limited 429' },
|
||||||
],
|
],
|
||||||
// CAPTCHA — must pre-empt LOGIN_FAILED_RE because both match. The
|
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||||
// suffix detector is independent of the leading verb so it catches the
|
// unpatched upstream v0.7.6.
|
||||||
// case even if Discord changes the body wrapping.
|
|
||||||
[
|
[
|
||||||
'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
|
'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
|
||||||
{ kind: 'captcha_required' },
|
{ kind: 'captcha_required' },
|
||||||
],
|
],
|
||||||
|
// Vojo-patched bridge — structured hCaptcha challenge. Sentinel prefix
|
||||||
|
// is checked before any regex so the JSON body is never misclassified.
|
||||||
|
// Use a realistic-shape rqtoken (JWT-style segments separated by `.`,
|
||||||
|
// base64url payload with `_`/`-`/`=`) so a regression where the regex
|
||||||
|
// accidentally trips on those characters is caught in CI.
|
||||||
|
[
|
||||||
|
'VOJO-CAPTCHA-CHALLENGE-V1 {"service":"hcaptcha","sitekey":"a9b5fb07-92ff-493f-86fe-352a2803b3df","session_id":"e971514e-4a6e-4a45-a869-01e61421327c","rqdata":"fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax","rqtoken":"IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A"}',
|
||||||
|
{
|
||||||
|
kind: 'captcha_challenge',
|
||||||
|
service: 'hcaptcha',
|
||||||
|
sitekey: 'a9b5fb07-92ff-493f-86fe-352a2803b3df',
|
||||||
|
sessionId: 'e971514e-4a6e-4a45-a869-01e61421327c',
|
||||||
|
rqdata:
|
||||||
|
'fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax',
|
||||||
|
rqtoken:
|
||||||
|
'IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Malformed challenge JSON falls through to `unknown` so a corrupted
|
||||||
|
// bridge build doesn't crash the parser.
|
||||||
|
['VOJO-CAPTCHA-CHALLENGE-V1 {not-json', { kind: 'unknown' }],
|
||||||
[
|
[
|
||||||
'Error connecting to login websocket: dial tcp i/o timeout',
|
'Error connecting to login websocket: dial tcp i/o timeout',
|
||||||
{ kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
|
{ kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,23 @@ export type LoginEvent =
|
||||||
// bodies: «Error logging in: ...» — we surface the verbatim Go-error tail
|
// bodies: «Error logging in: ...» — we surface the verbatim Go-error tail
|
||||||
// as a yellow warning on the QR panel.
|
// as a yellow warning on the QR panel.
|
||||||
| { kind: 'login_failed'; reason?: string }
|
| { kind: 'login_failed'; reason?: string }
|
||||||
// Special-cased login_failed branch: the bridge appends «CAPTCHAs are
|
// Vojo-patched bridge replies with a sentinel-prefixed JSON line
|
||||||
// currently not supported - use token login instead» when Discord
|
// («VOJO-CAPTCHA-CHALLENGE-V1 {...}») when Discord demands an hCaptcha
|
||||||
// presents a captcha. Telegram never sees this; Discord's remoteauth
|
// before completing remote-auth. The widget renders a hCaptcha iframe
|
||||||
// throws CAPTCHA roughly proportionally to the user's account age and
|
// with the supplied sitekey + rqdata, then sends the solved token back
|
||||||
// login frequency. Promotes to a hint-with-explanation banner instead
|
// via `<commandPrefix> login-captcha <token>` to retry the login.
|
||||||
// of a raw stack-trace tail.
|
| {
|
||||||
|
kind: 'captcha_challenge';
|
||||||
|
service: string;
|
||||||
|
sitekey: string;
|
||||||
|
sessionId: string;
|
||||||
|
rqdata: string;
|
||||||
|
rqtoken: string;
|
||||||
|
}
|
||||||
|
// Legacy «CAPTCHAs are currently not supported - use token login instead»
|
||||||
|
// path — only fires against an UNPATCHED upstream v0.7.6 bridge. Kept so
|
||||||
|
// a deployment that forgets to ship the Vojo bridge image still surfaces
|
||||||
|
// a useful hint instead of a generic Go-error tail.
|
||||||
| { kind: 'captcha_required' }
|
| { kind: 'captcha_required' }
|
||||||
// bridge sets up a websocket against Discord's remoteauth gateway; this is
|
// bridge sets up a websocket against Discord's remoteauth gateway; this is
|
||||||
// the «we couldn't even reach Discord» error — different from
|
// the «we couldn't even reach Discord» error — different from
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,19 @@ export const EN: Record<StringKey, string> = {
|
||||||
'auth-card.qr.step-1': 'Open the Discord mobile app.',
|
'auth-card.qr.step-1': 'Open the Discord mobile app.',
|
||||||
'auth-card.qr.step-2': 'Open Settings → Scan QR Code.',
|
'auth-card.qr.step-2': 'Open Settings → Scan QR Code.',
|
||||||
'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.',
|
'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.',
|
||||||
|
'auth-card.captcha.title': 'Confirm you’re not a robot',
|
||||||
|
'auth-card.captcha.hint':
|
||||||
|
'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once you’re done.',
|
||||||
|
'auth-card.captcha.load-error':
|
||||||
|
'Could not load the CAPTCHA. Check your network, tap Cancel and try signing in again.',
|
||||||
'auth-card.cancel': 'Cancel',
|
'auth-card.cancel': 'Cancel',
|
||||||
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||||
'auth-error.captcha-required':
|
'auth-error.captcha-required':
|
||||||
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||||
|
'auth-error.captcha-send-failed':
|
||||||
|
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||||
|
'auth-error.captcha-expired':
|
||||||
|
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||||
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||||
|
|
@ -72,6 +81,7 @@ export const EN: Record<StringKey, string> = {
|
||||||
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||||
'diag.qr-issued': 'QR code issued.',
|
'diag.qr-issued': 'QR code issued.',
|
||||||
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||||
|
'diag.captcha-issued': 'Discord requested a CAPTCHA — solve it in the form above.',
|
||||||
'bootstrap.failed': 'Widget failed to start',
|
'bootstrap.failed': 'Widget failed to start',
|
||||||
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
||||||
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,17 @@ export const RU = {
|
||||||
'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.',
|
'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.',
|
||||||
'auth-card.qr.step-2': 'Откройте «Настройки → Сканировать QR-код».',
|
'auth-card.qr.step-2': 'Откройте «Настройки → Сканировать QR-код».',
|
||||||
'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.',
|
'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.',
|
||||||
|
// --- hCaptcha challenge -----------------------------------------------
|
||||||
|
// Discord иногда требует решить hCaptcha перед завершением remoteauth —
|
||||||
|
// anti-abuse-сигнал, не зависящий от конкретного юзера. Vojo-патч
|
||||||
|
// отдаёт челлендж сюда в виджет; пользователь решает, токен уходит
|
||||||
|
// обратно на мост и логин завершается. Текст не упоминает «бан»: это
|
||||||
|
// обычный механизм Discord, а не санкция.
|
||||||
|
'auth-card.captcha.title': 'Подтвердите, что вы не робот',
|
||||||
|
'auth-card.captcha.hint':
|
||||||
|
'Discord попросил решить капчу. Решите её ниже — после этого вход продолжится автоматически.',
|
||||||
|
'auth-card.captcha.load-error':
|
||||||
|
'Не удалось загрузить капчу. Проверьте сеть и нажмите «Отмена», затем войдите снова.',
|
||||||
// --- Shared form chrome ------------------------------------------------
|
// --- Shared form chrome ------------------------------------------------
|
||||||
// Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены
|
// Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены
|
||||||
// активного login-qr, поэтому кнопка просто возвращает виджет в
|
// активного login-qr, поэтому кнопка просто возвращает виджет в
|
||||||
|
|
@ -73,6 +84,10 @@ export const RU = {
|
||||||
// --- Inline errors -----------------------------------------------------
|
// --- Inline errors -----------------------------------------------------
|
||||||
'auth-error.captcha-required':
|
'auth-error.captcha-required':
|
||||||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||||
|
'auth-error.captcha-send-failed':
|
||||||
|
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||||
|
'auth-error.captcha-expired':
|
||||||
|
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||||
|
|
@ -104,6 +119,7 @@ export const RU = {
|
||||||
// Поэтому в логе только нейтральные диагностические строки.
|
// Поэтому в логе только нейтральные диагностические строки.
|
||||||
'diag.qr-issued': 'QR-код выдан.',
|
'diag.qr-issued': 'QR-код выдан.',
|
||||||
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||||
|
'diag.captcha-issued': 'Discord прислал CAPTCHA — решите её на форме выше.',
|
||||||
// --- Bootstrap failure -------------------------------------------------
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
'bootstrap.failed': 'Widget не запустился',
|
'bootstrap.failed': 'Widget не запустился',
|
||||||
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,17 @@ import type { LoginEvent } from './bridge-protocol/types';
|
||||||
export type LoginErrorFlag =
|
export type LoginErrorFlag =
|
||||||
| { kind: 'login_failed'; reason?: string }
|
| { kind: 'login_failed'; reason?: string }
|
||||||
| { kind: 'captcha_required' }
|
| { kind: 'captcha_required' }
|
||||||
|
// Local rollback flag — fired when the widget couldn't deliver
|
||||||
|
// `login-captcha <token>` to the bridge (transport / Matrix-API failure
|
||||||
|
// before the bridge even saw the command). Distinct from
|
||||||
|
// `login_failed` (which IS a bridge reply) so the UX can read «sign-in
|
||||||
|
// didn't reach the bot, retry» instead of hinting at a Discord-side
|
||||||
|
// problem the user can't act on.
|
||||||
|
| { kind: 'captcha_send_failed' }
|
||||||
|
// The captcha challenge timed out (rqtoken expiry on Discord's side)
|
||||||
|
// before the user solved it. Surfaced as a soft warning to retry
|
||||||
|
// login-qr from scratch.
|
||||||
|
| { kind: 'captcha_expired' }
|
||||||
| { kind: 'login_websocket_failed'; reason?: string }
|
| { kind: 'login_websocket_failed'; reason?: string }
|
||||||
| { kind: 'connect_after_login_failed'; reason?: string }
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||||
| { kind: 'prepare_login_failed'; reason?: string }
|
| { kind: 'prepare_login_failed'; reason?: string }
|
||||||
|
|
@ -47,6 +58,20 @@ export type PendingFormState = {
|
||||||
lastError?: LoginErrorFlag;
|
lastError?: LoginErrorFlag;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// hCaptcha challenge surfaced by the Vojo-patched bridge after Discord
|
||||||
|
// returned 400+captcha-required. The widget renders the hCaptcha iframe
|
||||||
|
// from `sitekey` + `rqdata`; on solve, the App sends `login-captcha
|
||||||
|
// <token>` and we transition to `qr_verifying` until the bridge replies.
|
||||||
|
export type CaptchaSolveState = {
|
||||||
|
kind: 'awaiting_captcha_solve';
|
||||||
|
service: string;
|
||||||
|
sitekey: string;
|
||||||
|
sessionId: string;
|
||||||
|
rqdata: string;
|
||||||
|
rqtoken: string;
|
||||||
|
firstShownAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type LoginState =
|
export type LoginState =
|
||||||
// Pre-handshake / pre-ping. Status pill: --faint.
|
// Pre-handshake / pre-ping. Status pill: --faint.
|
||||||
| { kind: 'unknown' }
|
| { kind: 'unknown' }
|
||||||
|
|
@ -57,6 +82,11 @@ export type LoginState =
|
||||||
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
||||||
// event. Status pill: --amber.
|
// event. Status pill: --amber.
|
||||||
| PendingFormState
|
| PendingFormState
|
||||||
|
// hCaptcha challenge from Discord — Vojo-patched bridge surfaced the
|
||||||
|
// sitekey + rqdata via `VOJO-CAPTCHA-CHALLENGE-V1` notice. The widget
|
||||||
|
// renders the hCaptcha iframe; on solve we send `login-captcha <token>`
|
||||||
|
// and transition to `qr_verifying`. Status pill: --amber.
|
||||||
|
| CaptchaSolveState
|
||||||
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
||||||
// know whether login succeeded. Held as an intermediate spinner until
|
// know whether login succeeded. Held as an intermediate spinner until
|
||||||
// the next bridge signal arrives. Status pill: --amber.
|
// the next bridge signal arrives. Status pill: --amber.
|
||||||
|
|
@ -85,12 +115,14 @@ export type LoginState =
|
||||||
|
|
||||||
// States that the hydrate path can restore after a reload. The QR panel
|
// States that the hydrate path can restore after a reload. The QR panel
|
||||||
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
||||||
// timeline; `qr_verifying` covers the post-scan pre-success interstitial.
|
// timeline; `qr_verifying` covers the post-scan pre-success interstitial;
|
||||||
// Other transient states (logging_out, reconnecting) deliberately don't
|
// `awaiting_captcha_solve` covers the case where the user reloads while
|
||||||
// survive — those are tied to live in-flight commands and would feel
|
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||||
// stuck on reload; the hydrate path falls through to live ping.
|
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||||
|
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||||
export type HydrateRestoredState =
|
export type HydrateRestoredState =
|
||||||
| PendingFormState
|
| PendingFormState
|
||||||
|
| CaptchaSolveState
|
||||||
| { kind: 'qr_verifying' };
|
| { kind: 'qr_verifying' };
|
||||||
|
|
||||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||||
|
|
@ -113,12 +145,34 @@ export type LoginAction =
|
||||||
// kept symmetrical with TG's reducer for shape consistency, but
|
// kept symmetrical with TG's reducer for shape consistency, but
|
||||||
// dispatching it doesn't trigger any send.
|
// dispatching it doesn't trigger any send.
|
||||||
| { kind: 'cancel_pending' }
|
| { kind: 'cancel_pending' }
|
||||||
|
// User finished an hCaptcha challenge — token is non-empty. Optimistic
|
||||||
|
// transition to `qr_verifying`; the App fires `login-captcha <token>`
|
||||||
|
// and the bridge's reply (`login_success` / chained `captcha_challenge`
|
||||||
|
// / `login_failed`) lands via the live event stream.
|
||||||
|
| { kind: 'submit_captcha_token' }
|
||||||
|
// Rollback for `submit_captcha_token` — the App couldn't deliver the
|
||||||
|
// command to the bridge. Routes back to disconnected with a localized
|
||||||
|
// error so the user sees what happened.
|
||||||
|
| { kind: 'captcha_send_failed' }
|
||||||
|
// hCaptcha challenge expired (server rqtoken TTL or local 90s timer).
|
||||||
|
// Routes to disconnected with a localized warn.
|
||||||
|
| { kind: 'captcha_expired' }
|
||||||
| { kind: 'hydrate'; state: HydrateRestoredState };
|
| { kind: 'hydrate'; state: HydrateRestoredState };
|
||||||
|
|
||||||
export const initialLoginState: LoginState = { kind: 'unknown' };
|
export const initialLoginState: LoginState = { kind: 'unknown' };
|
||||||
|
|
||||||
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
||||||
|
|
||||||
|
// States that a fresh captcha challenge can clobber: the QR scan has
|
||||||
|
// landed (or is mid-flight), or we're already showing a previous
|
||||||
|
// challenge that Discord chained on top.
|
||||||
|
const isCaptchaAcceptingState = (
|
||||||
|
s: LoginState
|
||||||
|
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||||
|
s.kind === 'awaiting_qr_scan' ||
|
||||||
|
s.kind === 'qr_verifying' ||
|
||||||
|
s.kind === 'awaiting_captcha_solve';
|
||||||
|
|
||||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||||
if (action.kind === 'hydrate') {
|
if (action.kind === 'hydrate') {
|
||||||
// hydrate is a one-shot mount-time seed. If a live event already
|
// hydrate is a one-shot mount-time seed. If a live event already
|
||||||
|
|
@ -157,6 +211,36 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
||||||
return { kind: 'disconnected' };
|
return { kind: 'disconnected' };
|
||||||
}
|
}
|
||||||
|
if (action.kind === 'submit_captcha_token') {
|
||||||
|
// User solved hCaptcha and we're about to fire login-captcha. Hold
|
||||||
|
// `qr_verifying` until the bridge replies (success / chained challenge
|
||||||
|
// / generic login_failed). Only honour from the captcha state — this
|
||||||
|
// action is App-emitted right after the hCaptcha callback, so it
|
||||||
|
// shouldn't ever fire from anywhere else, but defensive: a stale send
|
||||||
|
// shouldn't suddenly paint a verifying spinner over a connected pill.
|
||||||
|
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||||
|
return { kind: 'qr_verifying' };
|
||||||
|
}
|
||||||
|
if (action.kind === 'captcha_send_failed') {
|
||||||
|
// App couldn't ship the `login-captcha` command (transport / Matrix
|
||||||
|
// API failure before the bridge saw it). Roll the optimistic
|
||||||
|
// `qr_verifying` back to disconnected with a localized error.
|
||||||
|
//
|
||||||
|
// Honour ONLY from `qr_verifying` — narrowed from also accepting
|
||||||
|
// `awaiting_captcha_solve` to avoid clobbering a fresh chained
|
||||||
|
// captcha that may have arrived between the optimistic dispatch and
|
||||||
|
// the failed-send rollback. If the live state already moved to a
|
||||||
|
// newer challenge, the stale send-failure should be silently dropped.
|
||||||
|
if (state.kind !== 'qr_verifying') return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_send_failed' } };
|
||||||
|
}
|
||||||
|
if (action.kind === 'captcha_expired') {
|
||||||
|
// Local 90s timer or hCaptcha's expired-callback fired — the rqtoken
|
||||||
|
// is dead, the user has nothing to solve. Route to disconnected with
|
||||||
|
// a localized warn so they retry login-qr from scratch.
|
||||||
|
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_expired' } };
|
||||||
|
}
|
||||||
|
|
||||||
const event = action.event;
|
const event = action.event;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
|
|
@ -286,28 +370,50 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
case 'login_failed':
|
case 'login_failed':
|
||||||
// Generic Discord-side login failure — bridge replies «Error logging
|
// Generic Discord-side login failure — bridge replies «Error logging
|
||||||
// in: <go-error>». Routes back to disconnected with the verbatim
|
// in: <go-error>». Routes back to disconnected with the verbatim
|
||||||
// reason as a warn line. Only honour when a QR flow is in flight;
|
// reason as a warn line. Only honour when a QR flow is in flight,
|
||||||
// otherwise it's stale (e.g. an old failure replaying after page
|
// OR while the user is solving a captcha (the Vojo-patched bridge
|
||||||
// reload while the user is already connected).
|
// can also reply «Error logging in: …» AFTER `login-captcha` if
|
||||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
// Discord rejects the post-solve replay). Otherwise it's stale
|
||||||
|
// (e.g. an old failure replaying after page reload while the user
|
||||||
|
// is already connected).
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
return {
|
return {
|
||||||
kind: 'disconnected',
|
kind: 'disconnected',
|
||||||
lastError: { kind: 'login_failed', reason: event.reason },
|
lastError: { kind: 'login_failed', reason: event.reason },
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'captcha_required':
|
case 'captcha_required':
|
||||||
// Discord presented a captcha during remoteauth — QR flow is dead
|
// UNPATCHED bridge fallback: «CAPTCHAs are currently not supported».
|
||||||
// for this attempt. Surface as a hint suggesting token-login (which
|
// Surface as a hint suggesting token-login (chat-fallback only). On
|
||||||
// we don't expose in the widget; users can do it via chat-fallback).
|
// a Vojo-patched bridge this branch never fires — see captcha_challenge.
|
||||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
||||||
|
|
||||||
|
case 'captcha_challenge':
|
||||||
|
// Vojo-patched bridge surfaced an hCaptcha challenge — pivot the
|
||||||
|
// widget to the captcha screen. Accept from awaiting_qr_scan
|
||||||
|
// (challenge landed before the QR was redacted), qr_verifying
|
||||||
|
// (challenge landed while we were still in the post-redact spinner)
|
||||||
|
// or awaiting_captcha_solve (Discord chained another challenge after
|
||||||
|
// the previous solve). Other states drop the event silently — a
|
||||||
|
// stale challenge from an abandoned flow shouldn't repaint UI.
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_captcha_solve',
|
||||||
|
service: event.service,
|
||||||
|
sitekey: event.sitekey,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
rqdata: event.rqdata,
|
||||||
|
rqtoken: event.rqtoken,
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
case 'login_websocket_failed':
|
case 'login_websocket_failed':
|
||||||
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
||||||
// never displayed in the first place. State `awaiting_qr_scan` with
|
// never displayed in the first place. State `awaiting_qr_scan` with
|
||||||
// empty discordUrl is the placeholder set by `start_qr_login`;
|
// empty discordUrl is the placeholder set by `start_qr_login`;
|
||||||
// this fires before the first qr_displayed lands.
|
// this fires before the first qr_displayed lands.
|
||||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
return {
|
return {
|
||||||
kind: 'disconnected',
|
kind: 'disconnected',
|
||||||
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
||||||
|
|
@ -318,14 +424,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
// connect to Discord with it. The bridge has the token cached and
|
// connect to Discord with it. The bridge has the token cached and
|
||||||
// might recover on next ping; we still route to disconnected so the
|
// might recover on next ping; we still route to disconnected so the
|
||||||
// user can retry.
|
// user can retry.
|
||||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
return {
|
return {
|
||||||
kind: 'disconnected',
|
kind: 'disconnected',
|
||||||
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'prepare_login_failed':
|
case 'prepare_login_failed':
|
||||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
return {
|
return {
|
||||||
kind: 'disconnected',
|
kind: 'disconnected',
|
||||||
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
||||||
|
|
@ -441,6 +547,12 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
||||||
// remoteauth needs the tighter window.
|
// remoteauth needs the tighter window.
|
||||||
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
||||||
|
// hCaptcha rqtoken is even more short-lived than the QR ticket — Discord
|
||||||
|
// invalidates after ~90s in practice. If the user reloads while staring
|
||||||
|
// at a captcha challenge older than this, restoring the captcha screen
|
||||||
|
// only sets them up for a server-side rejection on solve. Drop the state
|
||||||
|
// instead and let live ping reconcile.
|
||||||
|
const CAPTCHA_HYDRATE_FRESHNESS_MS = 90 * 1000;
|
||||||
|
|
||||||
export type HydrateInput = {
|
export type HydrateInput = {
|
||||||
ev: LoginEvent;
|
ev: LoginEvent;
|
||||||
|
|
@ -520,6 +632,41 @@ const stepHydrate = (
|
||||||
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'captcha_challenge': {
|
||||||
|
// SECURITY: only accept a captcha challenge if the same chain has
|
||||||
|
// already seen a `qr_displayed` (i.e. WE initiated the login).
|
||||||
|
// Otherwise a malicious / compromised homeserver could craft an
|
||||||
|
// m.notice with the sentinel JSON pointing at an attacker-controlled
|
||||||
|
// sitekey, the user solves it on reload, and the resulting hCaptcha
|
||||||
|
// token is sent verbatim to the bridge — useful free captcha-solving
|
||||||
|
// labour for the attacker, and a phishing surface. The live reducer
|
||||||
|
// already gates on `isCaptchaAcceptingState` (which requires we're
|
||||||
|
// mid-flow), but the hydrate path replays raw timeline events
|
||||||
|
// without the live state — drop unsolicited challenges here.
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan' && acc.state.kind !== 'qr_verifying') {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
// Vojo-patched bridge surfaced an hCaptcha — keep the chain open so a
|
||||||
|
// later `login_success` / `login_failed` still lands as terminal.
|
||||||
|
// The rqdata/rqtoken are short-lived on Discord's side (~2 min);
|
||||||
|
// the captcha-specific freshness gate in `hydrateFromTimeline`
|
||||||
|
// (CAPTCHA_HYDRATE_FRESHNESS_MS) drops stale states before they
|
||||||
|
// surface to the user.
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_captcha_solve',
|
||||||
|
service: ev.service,
|
||||||
|
sitekey: ev.sitekey,
|
||||||
|
sessionId: ev.sessionId,
|
||||||
|
rqdata: ev.rqdata,
|
||||||
|
rqtoken: ev.rqtoken,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Terminal events — collapse the chain. State becomes whatever the
|
// Terminal events — collapse the chain. State becomes whatever the
|
||||||
// bot confirmed last; the caller returns null and lets live `ping`
|
// bot confirmed last; the caller returns null and lets live `ping`
|
||||||
// reconcile.
|
// reconcile.
|
||||||
|
|
@ -570,8 +717,16 @@ export const hydrateFromTimeline = (
|
||||||
|
|
||||||
if (acc.terminated) return null;
|
if (acc.terminated) return null;
|
||||||
if (acc.pendingTs === null) return null;
|
if (acc.pendingTs === null) return null;
|
||||||
if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null;
|
// Tighter freshness gate for captcha state — rqtoken expires faster
|
||||||
|
// than the QR ticket. This protects the user from a "solved captcha,
|
||||||
|
// bridge rejects, user confused" UX after a slow reload.
|
||||||
|
const freshness =
|
||||||
|
acc.state.kind === 'awaiting_captcha_solve'
|
||||||
|
? CAPTCHA_HYDRATE_FRESHNESS_MS
|
||||||
|
: HYDRATE_FRESHNESS_MS;
|
||||||
|
if (now - acc.pendingTs > freshness) return null;
|
||||||
if (acc.state.kind === 'qr_verifying') return acc.state;
|
if (acc.state.kind === 'qr_verifying') return acc.state;
|
||||||
|
if (acc.state.kind === 'awaiting_captcha_solve') return acc.state;
|
||||||
if (!isFormState(acc.state)) return null;
|
if (!isFormState(acc.state)) return null;
|
||||||
return acc.state;
|
return acc.state;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -582,6 +582,34 @@ body {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── hCaptcha challenge panel ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-card-captcha {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hCaptcha renders its own iframe inside this host. We just provide a
|
||||||
|
* minimum frame height so the layout doesn't collapse during script load,
|
||||||
|
* and centre the iframe horizontally. The hCaptcha widget is a fixed-
|
||||||
|
* width 304px element by default; on narrow viewports we let it overflow
|
||||||
|
* its container's flex line rather than try to scale the iframe (the
|
||||||
|
* upstream SDK handles its own responsive variants). */
|
||||||
|
.auth-card-captcha-frame {
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
.auth-card-captcha-host {
|
||||||
|
/* hCaptcha injects a 304x78 (normal) or 156x144 (compact) iframe. The
|
||||||
|
* default rendering is 'normal' — we size accordingly and let the SDK
|
||||||
|
* place its own iframe inside. */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.auth-card-row {
|
.auth-card-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,19 @@ const config: CapacitorConfig = {
|
||||||
// request.isForMainFrame(), so any cross-origin iframe URL not in
|
// request.isForMainFrame(), so any cross-origin iframe URL not in
|
||||||
// appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW
|
// appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW
|
||||||
// and the iframe stays blank. Same-origin /widgets/... is safe (resolves
|
// and the iframe stays blank. Same-origin /widgets/... is safe (resolves
|
||||||
// to https://localhost under Capacitor). For HTTPS-hosted bot widgets,
|
// to https://localhost under Capacitor).
|
||||||
// uncomment and edit the host list below BEFORE shipping a config.json
|
|
||||||
// that points at them:
|
|
||||||
//
|
//
|
||||||
// server: { allowNavigation: ['app.vojo.chat', '*.vojo.chat'] },
|
// The Discord widget renders a nested hCaptcha iframe inside the
|
||||||
|
// widgets.vojo.chat frame; without `*.hcaptcha.com` in the allowlist
|
||||||
|
// the captcha challenge stays blank on Android and login is dead-ended.
|
||||||
|
server: {
|
||||||
|
allowNavigation: [
|
||||||
|
'widgets.vojo.chat',
|
||||||
|
'js.hcaptcha.com',
|
||||||
|
'newassets.hcaptcha.com',
|
||||||
|
'*.hcaptcha.com',
|
||||||
|
],
|
||||||
|
},
|
||||||
android: {
|
android: {
|
||||||
// Keep default: resolveServiceWorkerRequests = true
|
// Keep default: resolveServiceWorkerRequests = true
|
||||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue