diff --git a/.test/setup-env.js b/.test/setup-env.js index bdf31b2524..69c2b8ebdf 100644 --- a/.test/setup-env.js +++ b/.test/setup-env.js @@ -17,6 +17,7 @@ process.env.ALGOLIA_ID = 'ooo'; process.env.ALGOLIA_KEY = 'ooo'; process.env.ALGOLIA_SEARCH_KEY = 'ooo'; process.env.JWT_SIGNING_SECRET = 'shhhhhh'; +process.env.BYPASS_CAPTCHA = 'true'; process.env.FIREBASE_TEST_DB_URL = 'http://localhost:9875?ns=pubpub-v6'; process.env.ZOTERO_CLIENT_KEY = 'abc'; process.env.ZOTERO_CLIENT_SECRET = 'def'; diff --git a/client/components/Altcha/Altcha.tsx b/client/components/Altcha/Altcha.tsx new file mode 100644 index 0000000000..1289a6c381 --- /dev/null +++ b/client/components/Altcha/Altcha.tsx @@ -0,0 +1,199 @@ +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; + +import { usePageContext } from 'utils/hooks'; + +export type AltchaRef = { + value: string | null; + verify: () => Promise; +}; + +type AltchaProps = { + challengeurl?: string; + auto?: 'off' | 'onfocus' | 'onload' | 'onsubmit'; + onStateChange?: (ev: Event | CustomEvent<{ payload?: string; state: string }>) => void; + style?: React.CSSProperties & Record; +}; + +const DEFAULT_CHALLENGE_URL = '/api/captcha/challenge'; + +type WidgetElement = HTMLElement & AltchaWidgetMethods; + +const Altcha = forwardRef((props, ref) => { + const { challengeurl = DEFAULT_CHALLENGE_URL, auto, onStateChange, style } = props; + const { locationData } = usePageContext(); + const devMode = !locationData.isProd; + const widgetRef = useRef(null); + const [value, setValue] = useState(null); + const [loaded, setLoaded] = useState(false); + const [simulateFailure, setSimulateFailure] = useState(false); + const [widgetKey, setWidgetKey] = useState(0); + const valueRef = useRef(null); + valueRef.current = value; + + useEffect(() => { + import('altcha').then(() => setLoaded(true)); + }, []); + + const [altchaVisible, setAltchaVisible] = useState(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-attach after remount + useEffect(() => { + if (!loaded) return; + const w = widgetRef.current; + if (!w) return; + const handleStateChange = (ev: Event) => { + const e = ev as CustomEvent<{ payload?: string; state: string }>; + console.log('state changed', e.detail); + + switch (e.detail.state) { + case 'error': + case 'code': + case 'unverified': + setAltchaVisible(true); + break; + case 'verifying': + if (devMode) { + setAltchaVisible(true); + } + break; + case 'verified': + if (e.detail.payload) { + setValue(e.detail.payload); + setAltchaVisible(false); + } + break; + default: + break; + } + + onStateChange?.(e); + }; + w.addEventListener('statechange', handleStateChange); + return () => w.removeEventListener('statechange', handleStateChange); + }, [loaded, onStateChange, widgetKey]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-bind after remount + useImperativeHandle( + ref, + () => ({ + get value() { + return valueRef.current; + }, + verify(): Promise { + const w = widgetRef.current; + if (!w) return Promise.reject(new Error('Altcha widget not mounted')); + const current = valueRef.current; + if (current) return Promise.resolve(current); + return new Promise((resolve, reject) => { + const handler = (ev: Event) => { + const e = ev as CustomEvent<{ payload?: string; state: string }>; + const state = e.detail?.state; + if (state === 'verified' && e.detail?.payload) { + w.removeEventListener('statechange', handler); + resolve(e.detail.payload); + return; + } + if (state === 'error' || state === 'expired') { + w.removeEventListener('statechange', handler); + reject(new Error('Captcha verification failed')); + } + }; + w.addEventListener('statechange', handler); + w.verify(); + }); + }, + }), + [widgetKey], + ); + + const handleToggleFailure = () => { + setSimulateFailure((prev) => !prev); + setValue(null); + setWidgetKey((k) => k + 1); + }; + + const handleReset = () => { + setValue(null); + widgetRef.current?.reset(); + }; + + if (!loaded) return null; + + const devAttrs = devMode ? { debug: true, floatingpersist: 'focus' as const } : {}; + + const widget = ( + + + + ); + + if (!devMode) return widget; + + return ( +
+ {widget} + Captcha + + +
+ ); +}); + +Altcha.displayName = 'Altcha'; + +export default Altcha; diff --git a/client/components/Altcha/index.ts b/client/components/Altcha/index.ts new file mode 100644 index 0000000000..236dd78b2e --- /dev/null +++ b/client/components/Altcha/index.ts @@ -0,0 +1 @@ +export { type AltchaRef, default } from './Altcha'; diff --git a/client/components/GlobalControls/CreatePubButton.tsx b/client/components/GlobalControls/CreatePubButton.tsx index aea3c48b5e..0329167eca 100644 --- a/client/components/GlobalControls/CreatePubButton.tsx +++ b/client/components/GlobalControls/CreatePubButton.tsx @@ -1,35 +1,72 @@ -import React, { useState } from 'react'; +import type * as types from 'types'; + +import React, { useCallback, useRef, useState } from 'react'; import { apiFetch } from 'client/utils/apiFetch'; +import { Altcha, Honeypot } from 'components'; import { usePageContext } from 'utils/hooks'; import GlobalControlsButton from './GlobalControlsButton'; const CreatePubButton = () => { const [isLoading, setIsLoading] = useState(false); + const altchaRef = useRef(null); const { communityData } = usePageContext(); - const handleCreatePub = () => { - setIsLoading(true); - return apiFetch - .post('/api/pubs', { communityId: communityData.id }) - .then((newPub) => { + const handleCreatePub = useCallback( + async (evt: React.FormEvent) => { + evt.preventDefault(); + const formData = new FormData(evt.currentTarget); + const honeypot = (formData.get('description') as string) ?? ''; + setIsLoading(true); + try { + const altchaPayload = await altchaRef.current?.verify(); + if (!altchaPayload) return; + const newPub = await apiFetch.post('/api/pubs/fromForm', { + communityId: communityData.id, + altcha: altchaPayload, + _honeypot: honeypot, + }); window.location.href = `/pub/${newPub.slug}`; - }) - .catch((err) => { - console.error(err); + } catch (error) { + console.error('Error in handleCreatePub', error); + } finally { setIsLoading(false); - }); - }; + } + }, + [communityData.id], + ); + + const formRef = useRef(null); return ( - +
+ { + if (!isLoading) { + setIsLoading(true); + } + // @ts-ignore + if (state.detail.state === 'verified') { + setIsLoading(false); + } + }} + /> + + + ); }; diff --git a/client/components/Honeypot/Honeypot.tsx b/client/components/Honeypot/Honeypot.tsx new file mode 100644 index 0000000000..88be37c7ab --- /dev/null +++ b/client/components/Honeypot/Honeypot.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { usePageContext } from 'utils/hooks'; + +import './honeypot.scss'; + +type HoneypotProps = { + name: string; +}; + +const Honeypot = (props: HoneypotProps) => { + const { name } = props; + const { locationData } = usePageContext(); + const devMode = !locationData.isProd; + + if (devMode) { + return ( + + ); + } + + return ( + + ); +}; + +export default Honeypot; diff --git a/client/components/Honeypot/honeypot.scss b/client/components/Honeypot/honeypot.scss new file mode 100644 index 0000000000..a35b7dc422 --- /dev/null +++ b/client/components/Honeypot/honeypot.scss @@ -0,0 +1,8 @@ +.honeypot-input { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} diff --git a/client/components/Honeypot/index.ts b/client/components/Honeypot/index.ts new file mode 100644 index 0000000000..e8624180f0 --- /dev/null +++ b/client/components/Honeypot/index.ts @@ -0,0 +1 @@ +export { default } from './Honeypot'; diff --git a/client/components/Layout/LayoutBanner.tsx b/client/components/Layout/LayoutBanner.tsx index 5ae562b5aa..30689b18d0 100644 --- a/client/components/Layout/LayoutBanner.tsx +++ b/client/components/Layout/LayoutBanner.tsx @@ -1,9 +1,10 @@ -import React, { Component } from 'react'; +import React, { Component, createRef } from 'react'; -import { AnchorButton, Classes, Tooltip } from '@blueprintjs/core'; +import { AnchorButton, Button, Classes, Tooltip } from '@blueprintjs/core'; import Color from 'color'; import { apiFetch } from 'client/utils/apiFetch'; +import { Altcha, Honeypot } from 'components'; import { getResizedUrl } from 'utils/images'; import './layoutBanner.scss'; @@ -34,6 +35,8 @@ const createPubFailureText = 'Error creating a new Pub. You may want to refresh the page and try again.'; class LayoutBanner extends Component { + altchaRef = createRef(); + constructor(props: Props) { super(props); this.state = { @@ -43,16 +46,25 @@ class LayoutBanner extends Component { this.createPub = this.createPub.bind(this); } - createPub() { + createPub(evt: React.FormEvent) { + evt.preventDefault(); + const formData = new FormData(evt.currentTarget); + const honeypot = (formData.get('description') as string) ?? ''; const { communityData, content } = this.props; this.setState({ isLoading: true, buttonError: null }); - return apiFetch('/api/pubs', { - method: 'POST', - body: JSON.stringify({ - communityId: communityData.id, - createPubToken: content.createPubToken, - }), - }) + this.altchaRef.current + ?.verify() + .then((altchaPayload) => + apiFetch('/api/pubs/fromForm', { + method: 'POST', + body: JSON.stringify({ + communityId: communityData.id, + createPubToken: content.createPubToken, + altcha: altchaPayload, + _honeypot: honeypot, + }), + }), + ) .then((newPub) => { window.location.href = `/pub/${newPub.slug}`; this.setState({ isLoading: false }); @@ -85,18 +97,37 @@ class LayoutBanner extends Component { buttonUrl = 'signup'; } - const onButtonClick = - (isLoggedIn && buttonType === 'create-pub' && this.createPub) || undefined; - - const button = ( - - ); + // const onButtonClick = + // (isLoggedIn && buttonType === 'create-pub' && this.createPub) || undefined; + + let button: React.ReactNode; + + if (isLoggedIn && buttonType === 'create-pub') { + button = ( +
+ + +