mirror of
https://github.com/altcha-org/altcha.git
synced 2026-01-25 04:16:41 +00:00
0.7.0
This commit is contained in:
34
README.md
34
README.md
@@ -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
2
dist/altcha.d.ts
vendored
@@ -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
1782
dist/altcha.js
vendored
File diff suppressed because it is too large
Load Diff
4
dist/altcha.umd.cjs
vendored
4
dist/altcha.umd.cjs
vendored
File diff suppressed because one or more lines are too long
2
dist_external/altcha.d.ts
vendored
2
dist_external/altcha.d.ts
vendored
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
2
src/declarations.d.ts
vendored
2
src/declarations.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
168
src/session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ export interface Strings {
|
||||
}
|
||||
|
||||
export interface Configure {
|
||||
analytics?: boolean;
|
||||
auto?: 'onfocus' | 'onload' | 'onsubmit';
|
||||
beaconurl?: string;
|
||||
challenge?: Challenge;
|
||||
challengeurl?: string;
|
||||
debug?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user