This commit is contained in:
Daniel Regeci
2024-08-04 16:22:41 -03:00
parent 5d0b41c094
commit 46f9983c34
14 changed files with 2126 additions and 1548 deletions

View File

@@ -26,6 +26,8 @@ https://altcha.org
- [PHP](https://github.com/altcha-org/altcha-lib-php)
- [Go](https://github.com/altcha-org/altcha-lib-go)
- [Python](https://github.com/altcha-org/altcha-lib-py)
- [Java](https://github.com/altcha-org/altcha-lib-java)
- [Ruby](https://github.com/altcha-org/altcha-lib-rb)
## Plugins
@@ -89,7 +91,9 @@ Required options (at least one is required):
Additional options:
- __analytics__ - Enable analytics with [ALTCHA Forms](https://altcha.org/forms/). See [HTML submissions documentation](https://altcha.org/docs/forms/features/html-submissions).
- __auto__ - Automatically verify without user interaction (possible values: `onfocus`, `onload`, `onsubmit`).
- __beaconurl__ - URL to which analytics data will be sent using a beacon POST if the form is abandoned. This option is only used when `analytics` is enabled.
- __blockspam__ - Only used in conjunction with the `spamfilter` option. If enabled, it will block form submission and fail verification if the Spam Filter returns a negative classification. This effectively prevents submission of the form.
- __delay__ - The artificial delay in milliseconds to apply before the verification (defaults to 0).
- __expire__ - The challenge expiration (duration in milliseconds).
@@ -103,7 +107,7 @@ Additional options:
- __spamfilter__ - Enable [Spam Filter](#spam-filter).
- __strings__ - JSON-encoded translation strings. Refer to [customization](/docs/widget-customization).
- __refetchonexpire__ - Automatically re-fetch and re-validate when the challenge expires (defaults to true).
- __verifyurl__ - Enable server-side verification by configuring the URL to use for verification requests. This option can be used in conjunction with `spamfilter` to enable server-side verification.
- __verifyurl__ - URL for server-side verification requests. This option is automatically configured when the `spamfilter` option is used. Override this setting only if you are using a custom server implementation.
- __workers__ - The number of workers to utilize for PoW (defaults to `navigator.hardwareConcurrency || 8`, max. value `16`).
- __workerurl__ - The URL of the Worker script (defaults to `./worker.js`, only works with `external` build).
@@ -135,35 +139,41 @@ Available configuration options:
```ts
export interface Configure {
auto?: 'onload' | 'onsubmit';
analytics?: boolean | string;
auto?: 'onfocus' | 'onload' | 'onsubmit';
beaconurl?: string;
challenge?: {
algorithm: string;
challenge: string;
maxnumber?: number;
salt: string;
signature: string;
};
challengeurl?: string;
debug?: boolean;
delay?: number;
expire?: number;
floating?: 'auto' | 'top' | 'bottom';
floatinganchor?: string;
floatingoffset?: number;
autorenew?: boolean;
hidefooter?: boolean;
hidelogo?: boolean;
maxnumber?: number;
mockerror?: boolean;
name?: string;
refetchonexpire?: boolean;
spamfilter: boolean | 'ipAddress' | SpamFilter;
spamfilter?: boolean | 'ipAddress' | SpamFilter;
strings?: {
error?: string;
footer?: string;
label?: string;
verified?: string;
verifying?: string;
waitAlert?: string;
};
test?: boolean | number;
error: string;
expired: string;
footer: string;
label: string;
verified: string;
verifying: string;
waitAlert: string;
}
test?: boolean | number | 'delay';
verifyurl?: string;
workers?: number;
workerurl?: string;
@@ -196,7 +206,7 @@ document.querySelector('#altcha').addEventListener('statechange', (ev) => {
```
> [!IMPORTANT]
> Both programmatic configuration and event listeners have to called/attached after the ALTCHA script loads, such as within window.addEventListener('load', ...).
> Both programmatic configuration and event listeners have to called/attached after the ALTCHA script loads, such as within `window.addEventListener('load', ...)`.
## Spam Filter

2
dist/altcha.d.ts vendored
View File

@@ -17,7 +17,9 @@ declare global {
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
interface AltchaWidget {
analytics?: boolean | string;
auto?: 'onfocus' | 'onload' | 'onsubmit';
beaconurl?: string;
blockspam?: boolean;
challengeurl?: string;
challengejson?: string;

1782
dist/altcha.js vendored

File diff suppressed because it is too large Load Diff

4
dist/altcha.umd.cjs vendored

File diff suppressed because one or more lines are too long

View File

@@ -17,7 +17,9 @@ declare global {
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
interface AltchaWidget {
analytics?: boolean | string;
auto?: 'onfocus' | 'onload' | 'onsubmit';
beaconurl?: string;
blockspam?: boolean;
challengeurl?: string;
challengejson?: string;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "altcha",
"version": "0.6.7",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "altcha",
"version": "0.6.7",
"version": "0.7.0",
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.44.1",

View File

@@ -1,7 +1,7 @@
{
"name": "altcha",
"description": "GDPR compliant, self-hosted CAPTCHA alternative.",
"version": "0.6.7",
"version": "0.7.0",
"license": "MIT",
"author": {
"name": "Daniel Regeci",

View File

@@ -7,7 +7,8 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte';
import { solveChallenge, createTestChallenge } from './helpers';
import { solveChallenge, createTestChallenge, getTimeZone } from './helpers';
import { Session } from './session';
import { State } from './types';
import type {
Configure,
@@ -18,7 +19,9 @@
ServerVerificationPayload,
} from './types';
export let analytics: boolean = false;
export let auto: 'onfocus' | 'onload' | 'onsubmit' | undefined = undefined;
export let beaconurl: string | undefined = undefined
export let blockspam: boolean | undefined = undefined;
export let challengeurl: string | undefined = undefined;
export let challengejson: string | undefined = undefined;
@@ -46,7 +49,7 @@
export let test: boolean | number = false;
export let verifyurl: string | undefined = undefined;
export let workers: number = Math.min(16, navigator.hardwareConcurrency || 8);
export let workerurl: string | undefined = void 0;
export let workerurl: string | undefined = undefined;
const dispatch = createEventDispatcher();
const allowedAlgs = ['SHA-256', 'SHA-384', 'SHA-512'];
@@ -60,9 +63,11 @@
let elFloatingAnchor: HTMLElement | null = null;
let elForm: HTMLFormElement | null = null;
let error: string | null = null;
let payload: string | null = null;
let state: State = State.UNVERIFIED;
let expireTimeout: ReturnType<typeof setTimeout> | null = null;
let payload: string | null = null;
let session: Session | null = null;
let sessionPayload: string | null = null;
let state: State = State.UNVERIFIED;
$: isFreeSaaS =
!!challengeurl?.includes('.altcha.org') &&
@@ -83,6 +88,7 @@
...parsedStrings,
};
$: dispatch('statechange', { payload, state });
$: onErrorChange(error);
$: onStateChange(state);
onDestroy(() => {
@@ -92,6 +98,9 @@
elForm.removeEventListener('focusin', onFormFocusIn);
elForm = null;
}
if (session) {
session.destroy();
}
if (expireTimeout) {
clearTimeout(expireTimeout);
expireTimeout = null;
@@ -126,6 +135,9 @@
elForm.addEventListener('focusin', onFormFocusIn);
}
}
if (analytics) {
enableAnalytics();
}
if (auto === 'onload') {
verify();
}
@@ -149,6 +161,10 @@
}
function onFormSubmit(ev: SubmitEvent) {
if (elForm && session && state === State.VERIFIED) {
session.end();
sessionPayload = session.dataAsBase64();
}
if (elForm && auto === 'onsubmit') {
if (state === State.UNVERIFIED) {
ev.preventDefault();
@@ -219,14 +235,21 @@
log('generating test challenge', { test });
return createTestChallenge(typeof test !== 'boolean' ? +test : undefined);
} else {
if (!challengeurl && elForm) {
const action = elForm.getAttribute('action');
if (action?.includes('/form/')) {
// ALTCHA Forms url for challenges
challengeurl = action + '/altcha';
}
}
if (!challengeurl) {
throw new Error(`Attribute challengeurl not set.`);
}
log('fetching challenge from', challengeurl);
const resp = await fetch(challengeurl, {
headers: {
'x-altcha-spam-filter': !!spamfilter ? '1' : '0',
},
headers: !!spamfilter ? {
'x-altcha-spam-filter': '1',
} : {},
});
if (resp.status !== 200) {
throw new Error(`Server responded with ${resp.status}.`);
@@ -273,6 +296,26 @@
}
}
function enableAnalytics () {
if (session) {
// already enabled
return;
}
if (elForm) {
log('analytics enabled');
session = new Session(elForm);
if (beaconurl === undefined) {
const action = elForm.getAttribute('action');
if (action) {
beaconurl = action + '/beacon';
}
}
session.beaconUrl = beaconurl || null;
} else {
log('analytics cannot be enabled - form element not found');
}
}
function expireChallenge() {
if (challengeurl && refetchonexpire && state === State.VERIFIED) {
// re-fetch challenge and verify again
@@ -397,6 +440,12 @@
}
}
function onErrorChange(_: typeof error) {
if (session) {
session.trackError(error);
}
}
function onStateChange(_: typeof state) {
if (floating && state !== State.UNVERIFIED) {
requestAnimationFrame(() => {
@@ -480,15 +529,6 @@
);
}
function getTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
// noop
}
return undefined;
}
async function requestServerVerification(verificationPayload: string) {
if (!verifyurl) {
throw new Error('Attribute verifyurl not set.');
@@ -629,12 +669,22 @@
}
export function configure(options: Configure) {
if (options.analytics) {
analytics = options.analytics;
enableAnalytics();
}
if (options.auto !== undefined) {
auto = options.auto;
if (auto === 'onload') {
verify();
}
}
if (options.beaconurl) {
beaconurl = options.beaconurl;
if (session) {
session.beaconUrl = beaconurl;
}
}
if (options.floatinganchor !== undefined) {
floatinganchor = options.floatinganchor;
}
@@ -798,6 +848,10 @@
{#if state === State.VERIFIED}
<span>{@html _strings.verified}</span>
<input type="hidden" {name} value={payload} />
{#if session}
<input type="hidden" name="__session" value={sessionPayload} />
{/if}
{:else if state === State.VERIFYING}
<span>{@html _strings.verifying}</span>
{:else}

View File

@@ -17,7 +17,9 @@ declare global {
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
interface AltchaWidget {
analytics?: boolean | string;
auto?: 'onfocus' | 'onload' | 'onsubmit';
beaconurl?: string;
blockspam?: boolean;
challengeurl?: string;
challengejson?: string;

View File

@@ -68,3 +68,13 @@ export function solveChallenge(
controller,
};
}
export function getTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
// noop
}
return undefined;
}

168
src/session.ts Normal file
View File

@@ -0,0 +1,168 @@
import { getTimeZone } from './helpers';
export class Session {
beaconUrl: string | null = null;
error: string | null = null;
readonly loadTime: number = Date.now();
submitTime: number | null = null;
startTime: number | null = null;
viewTimeThresholdMs: number = 1500;
readonly #fieldChanges: Record<string, number> = {};
#lastInputName: string | null = null;
readonly #onFormChange = this.onFormChange.bind(this);
readonly #onFormFocus = this.onFormFocus.bind(this);
readonly #onFormSubmit = this.onFormSubmit.bind(this);
readonly #onUnload = this.onUnload.bind(this);
constructor(readonly elForm: HTMLFormElement) {
window.addEventListener('unload', this.#onUnload);
this.elForm.addEventListener('change', this.#onFormChange);
this.elForm.addEventListener('focusin', this.#onFormFocus);
this.elForm.addEventListener('submit', this.#onFormSubmit);
}
data() {
const fields = Object.entries(this.#fieldChanges);
return {
correction: fields.length
? fields.filter(([_, changes]) => changes > 1).length / fields.length ||
0
: 0,
dropoff:
!this.submitTime && !this.error && this.#lastInputName
? this.#lastInputName
: null,
error: this.error,
mobile: this.isMobile(),
start: this.startTime,
submit: this.submitTime,
tz: getTimeZone(),
};
}
dataAsBase64() {
try {
return btoa(
JSON.stringify(this.data())
);
} catch (err) {
console.error('failed to encode ALTCHA session data to base64', err);
}
return '';
}
destroy() {
window.removeEventListener('unload', this.#onUnload);
this.elForm.removeEventListener('change', this.#onFormChange);
this.elForm.removeEventListener('focusin', this.#onFormFocus);
this.elForm.removeEventListener('submit', this.#onFormSubmit);
}
end() {
if (!this.submitTime) {
this.submitTime = Date.now();
}
}
getFieldName(el: HTMLInputElement, maxLength: number = 40) {
const group = el.getAttribute('data-group-label');
const name = el.getAttribute('name') || el.getAttribute('aria-label');
return ((group ? group + ': ' : '') + name).slice(0, maxLength);
}
isMobile(): boolean {
const userAgentData =
'userAgentData' in navigator && navigator.userAgentData
? (navigator.userAgentData as Record<string, unknown>)
: {};
if ('mobile' in userAgentData) {
return userAgentData.mobile === true;
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
return /Mobi/i.test(window.navigator.userAgent);
}
isInput(el: HTMLElement) {
return ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName);
}
onFormFieldChange(el: HTMLInputElement) {
const name = this.getFieldName(el);
if (name) {
this.trackFieldChange(name);
}
}
onFormChange(ev: Event) {
const target = ev.target as HTMLInputElement | null;
if (target && this.isInput(target)) {
this.onFormFieldChange(target);
}
}
onFormFocus(ev: FocusEvent) {
const target = ev.target as HTMLInputElement | null;
if (!this.startTime) {
this.start();
}
if (target && this.isInput(target)) {
const name = this.getFieldName(target);
if (name) {
this.#lastInputName = name;
}
}
}
onFormSubmit() {
this.end();
}
onUnload() {
if (
this.loadTime <= Date.now() - this.viewTimeThresholdMs &&
!this.submitTime
) {
this.sendBeacon();
}
}
async sendBeacon() {
if (
this.beaconUrl &&
'sendBeacon' in navigator
) {
try {
navigator.sendBeacon(
new URL(this.beaconUrl, location.origin),
JSON.stringify(this.data())
);
} catch {
// noop
}
}
}
start() {
this.startTime = Date.now();
}
trackError(err: string | null) {
this.error = err === null ? null : String(err);
}
trackFieldChange(name: string) {
this.#fieldChanges[name] = (this.#fieldChanges[name] || 0) + 1;
}
}

View File

@@ -9,7 +9,9 @@ export interface Strings {
}
export interface Configure {
analytics?: boolean;
auto?: 'onfocus' | 'onload' | 'onsubmit';
beaconurl?: string;
challenge?: Challenge;
challengeurl?: string;
debug?: boolean;