Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
da876d7
feat: allow blocking users
tefkah Feb 10, 2026
174fffa
feat: improve blocking users
tefkah Feb 10, 2026
5c9d2bd
fix: lint, etc
tefkah Feb 10, 2026
26dff7a
feat: allow extra keywords to be added by env var
tefkah Feb 10, 2026
aec4f86
Merge branch 'master' into tfk/spam-superadminuser
tefkah Feb 11, 2026
1abb43d
fix: also stop showing user comment
tefkah Feb 12, 2026
396f6bd
chore: merge
tefkah Feb 12, 2026
ab023fc
fix: lint
tefkah Feb 12, 2026
7843395
fix: lint, fr
tefkah Feb 12, 2026
d75a949
feat: captchas and honeypots
tefkah Feb 12, 2026
36f22c8
feat: improved captchas and honeypots
tefkah Feb 12, 2026
3715229
fix: lint
tefkah Feb 12, 2026
31b9f95
fix: send slack message on ban/unban
tefkah Feb 12, 2026
f7eab42
fix: skip captchas in test
tefkah Feb 12, 2026
e7f7c42
fix
tefkah Feb 12, 2026
0428725
Merge branch 'main' into tfk/captcha-honeypot
tefkah Feb 25, 2026
5cf9d94
fix: typecheck
tefkah Feb 25, 2026
09ed4bf
Merge branch 'main' into tfk/captcha-honeypot
tefkah Feb 25, 2026
d23b55d
fix: allow superadmins to instaban from comments
tefkah Feb 25, 2026
6127ddb
fix: improve messages
tefkah Feb 25, 2026
c0bc69e
Merge branch 'main' into tfk/captcha-honeypot
tefkah Mar 3, 2026
3368dd8
fix: allow sorting and filtering spam users
tefkah Mar 3, 2026
70b572e
fix: fix create pub button flow
tefkah Mar 3, 2026
6e36379
fix: remove stupid test (bad claude)
tefkah Mar 3, 2026
55cfd53
refactor: make honeypot helper more sensible
tefkah Mar 3, 2026
e4a9fa4
refactor: modify altcha loading in replies a bit
tefkah Mar 3, 2026
408d810
chore: merge
tefkah Mar 3, 2026
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
1 change: 1 addition & 0 deletions .test/setup-env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
199 changes: 199 additions & 0 deletions client/components/Altcha/Altcha.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
};

type AltchaProps = {
challengeurl?: string;
auto?: 'off' | 'onfocus' | 'onload' | 'onsubmit';
onStateChange?: (ev: Event | CustomEvent<{ payload?: string; state: string }>) => void;
style?: React.CSSProperties & Record<string, string>;
};

const DEFAULT_CHALLENGE_URL = '/api/captcha/challenge';

type WidgetElement = HTMLElement & AltchaWidgetMethods;

const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
const { challengeurl = DEFAULT_CHALLENGE_URL, auto, onStateChange, style } = props;
const { locationData } = usePageContext();
const devMode = !locationData.isProd;
const widgetRef = useRef<WidgetElement | null>(null);
const [value, setValue] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [simulateFailure, setSimulateFailure] = useState(false);
const [widgetKey, setWidgetKey] = useState(0);
const valueRef = useRef<string | null>(null);
valueRef.current = value;

useEffect(() => {
import('altcha').then(() => setLoaded(true));
}, []);

const [altchaVisible, setAltchaVisible] = useState<boolean>(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);
Comment thread
tefkah marked this conversation as resolved.

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<string> {
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 = (
<React.Fragment key={widgetKey}>
<altcha-widget
delay={500}
ref={widgetRef as any}
challengeurl={challengeurl}
{...(auto ? { auto } : {})}
floating="auto"
{...devAttrs}
{...(simulateFailure ? { mockerror: true } : {})}
style={{
display: altchaVisible ? 'block' : 'none',
zIndex: 1000,
...(style ? ({ style } as any) : {}),
}}
// disable very annoying wait alert
strings="{&quot;waitAlert&quot;:&quot;&quot;}"
/>
</React.Fragment>
);

if (!devMode) return widget;

return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: '#5c7080',
padding: '2px 6px',
border: '1px dashed #5c7080',
borderRadius: 3,
}}
>
{widget}
<span style={{ fontWeight: 600 }}>Captcha</span>
<label
style={{
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 3,
color: simulateFailure ? '#db3737' : undefined,
}}
>
<input
type="checkbox"
checked={simulateFailure}
onChange={handleToggleFailure}
style={{ margin: 0 }}
/>
fail
</label>
<button
type="button"
onClick={handleReset}
style={{
fontSize: 11,
padding: '1px 6px',
cursor: 'pointer',
border: '1px solid #ced9e0',
borderRadius: 3,
background: 'white',
lineHeight: '16px',
}}
>
reset
</button>
</div>
);
});

Altcha.displayName = 'Altcha';

export default Altcha;
1 change: 1 addition & 0 deletions client/components/Altcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type AltchaRef, default } from './Altcha';
78 changes: 60 additions & 18 deletions client/components/GlobalControls/CreatePubButton.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,77 @@
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 formRef = useRef<HTMLFormElement>(null);
const altchaRef = useRef<import('components').AltchaRef>(null);
const { communityData } = usePageContext();

const handleCreatePub = () => {
const handleCreatePub = useCallback(async () => {
if (isLoading) {
return;
}

const formElement = formRef.current;
if (!formElement) {
return;
}

const formData = new FormData(formElement);
const honeypot = (formData.get('description') as string) ?? '';
setIsLoading(true);
return apiFetch
.post('/api/pubs', { communityId: communityData.id })
.then((newPub) => {
window.location.href = `/pub/${newPub.slug}`;
})
.catch((err) => {
console.error(err);
setIsLoading(false);
try {
const altchaPayload = await altchaRef.current?.verify();
if (!altchaPayload) return;
const newPub = await apiFetch.post<types.Pub>('/api/pubs/fromForm', {
communityId: communityData.id,
altcha: altchaPayload,
_honeypot: honeypot,
});
};
window.location.href = `/pub/${newPub.slug}`;
} catch (error) {
console.error('Error in handleCreatePub', error);
} finally {
setIsLoading(false);
}
}, [communityData.id, isLoading]);

return (
<GlobalControlsButton
loading={isLoading}
aria-label="Create Pub"
onClick={handleCreatePub}
desktop={{ text: 'Create Pub' }}
mobile={{ icon: 'pubDocNew' }}
/>
// the form might feel a little superfluous here, but it's necessary form anchoring altcha correctly
<form
onSubmit={(evt) => {
// we don't use the `onSubmit` handler to actually create the pub,
// because otherwise the following happens:
// 1. click "Create Pub" button
// 2. altcha reacts to first interaction with form, stops the event from propagating, and starts verifying
// 3. altcha verifies succesfully, but the form is not submitted, so the pub is not created
// 4. user needs to click the button again to create the pub

// this kinda sucks, so instead we just do it through the onClick handler. not really nice form semantics, but should be fine accessibility-wise.
evt.preventDefault();
}}
ref={formRef}
// only relevant in dev
style={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<Altcha ref={altchaRef} />
<Honeypot name="description" />
<GlobalControlsButton
onClick={handleCreatePub}
loading={isLoading}
aria-label="Create Pub"
type="submit"
desktop={{ text: 'Create Pub' }}
mobile={{ icon: 'pubDocNew' }}
/>
</form>
);
};

Expand Down
54 changes: 54 additions & 0 deletions client/components/Honeypot/Honeypot.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'orange',
fontWeight: 600,
padding: '2px 6px',
border: '1px dashed orange',
borderRadius: 3,
}}
>
Honeypot
<input
type="text"
name={name}
autoComplete="off"
style={{ width: 80, fontSize: 11, padding: '1px 4px' }}
/>
</label>
);
}

return (
<input
type="text"
className="honeypot-input"
name={name}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
);
};

export default Honeypot;
8 changes: 8 additions & 0 deletions client/components/Honeypot/honeypot.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.honeypot-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
1 change: 1 addition & 0 deletions client/components/Honeypot/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Honeypot';
Loading