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 (
-
+
);
};
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 = (
+
+ );
+ } else {
+ button = (
+
+ );
+ }
return buttonError ? (
diff --git a/client/components/SpamStatusMenu/SpamStatusMenu.tsx b/client/components/SpamStatusMenu/SpamStatusMenu.tsx
new file mode 100644
index 0000000000..ea02c2f90c
--- /dev/null
+++ b/client/components/SpamStatusMenu/SpamStatusMenu.tsx
@@ -0,0 +1,78 @@
+import type { SpamStatus } from 'types';
+
+import React, { useCallback, useState } from 'react';
+
+import { apiFetch } from 'client/utils/apiFetch';
+import { Icon } from 'components';
+import { MenuButton, MenuItem, MenuItemDivider } from 'components/Menu';
+
+type Props = {
+ userId: string;
+ currentStatus?: SpamStatus | null;
+ onStatusChanged?: (status: SpamStatus | null) => void;
+ small?: boolean;
+};
+
+const statusLabels: Record = {
+ 'confirmed-spam': { label: 'Mark as spam', icon: 'cross' },
+ 'confirmed-not-spam': { label: 'Mark as not spam', icon: 'tick' },
+ unreviewed: { label: 'Mark as unreviewed', icon: 'undo' },
+};
+
+const SpamStatusMenu = (props: Props) => {
+ const { userId, currentStatus = null, onStatusChanged, small = true } = props;
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleSetStatus = useCallback(
+ async (status: SpamStatus) => {
+ setIsLoading(true);
+ try {
+ await apiFetch.put('/api/spamTags/user', { status, userId });
+ onStatusChanged?.(status);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [userId, onStatusChanged],
+ );
+
+ const handleRemoveTag = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ await apiFetch.delete('/api/spamTags/user', { userId });
+ onStatusChanged?.(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [userId, onStatusChanged]);
+
+ return (
+ ,
+ minimal: true,
+ small,
+ loading: isLoading,
+ }}
+ >
+ {(Object.keys(statusLabels) as SpamStatus[]).map((status) => (
+