Skip to content
GulomovCreativePublic

About

Формы на MODX без перезагрузки: Fetch API, FormIt, защита от спама. Один пакет для MODX 2.8 и 3.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Latest commit

 

History

247 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FetchIt

MODX forms without a page reload, protected from spam out of the box.

MODX 2.8+ | 3.x PHP 7.4+ TypeScript CI License: GPL-2.0

English | Русский

FetchIt submits the forms of a MODX site with the Fetch API: no page reload, no jQuery or other libraries. The forms are processed by FormIt, with all its hooks and validation, or by a snippet of your own. Every form is protected from spam from the start: a single-use token, a fill time, a trap field and a limit of submissions, and a proof of work and a captcha when you need them. One package for MODX 2.8 and MODX 3.

Contents

Features

  • FormIt out of the box. Properties such as &hooks, &validate and &emailTo go to FormIt as they are, with no wrappers. Instead of FormIt you can name a snippet of your own that returns JSON (success, message, data).
  • Your own markup. All it takes is a form chunk with attributes: data-error for the errors of fields, data-success and data-validation-error for the messages of the form. The classes of invalid fields are system settings.
  • Works without JavaScript too. With FormIt the form is sent the usual way, and the messages and the values entered come back through its placeholders. Unless a proof of work or a captcha is on: they need JavaScript.
  • Spam protection by default: a signed single-use token, a minimum fill time, a hidden trap field and a limit of submissions. Optionally a proof of work and a captcha: Cloudflare Turnstile, Google reCAPTCHA v3 or Yandex SmartCaptcha. Plugins add rules of their own.
  • No dependencies. The script needs no jQuery or other libraries and uses the native Fetch API and FormData (files included). It is loaded with defer and loads no other files, except the script of the captcha provider when a captcha is on.
  • Built-in notifications, or your own through FetchIt.Message: Bootstrap, SweetAlert2, anything.
  • Events fetchit:before, fetchit:after, fetchit:success, fetchit:error, fetchit:reset: add data, cancel a submission, show a modal.
  • Redirect after a submission: &redirectTo, or a redirect made by your own snippet or a FormIt hook, goes to the browser as a part of the answer.
  • Timeout and retries: &timeout and &retries resend the form after a network failure, with one submission key, so a lost answer is not processed twice; the event fetchit:retry can stop a retry.
  • TypeScript types ship next to the script.
  • Several forms on a page, each with its own key and handler, as long as the snippet calls differ in their properties.
  • Fenom and @FILE chunks through pdoTools on MODX 2 and MODX 3.

Installation

FetchIt is free. Install it with the Package Manager from the official modx.com repository or from the modstore.pro marketplace (how to add the repository). When FormIt is not installed, the installer downloads it from modx.com; if that fails, the install log says so, and FormIt has to be installed by hand.

You can also build the package from this repository, see Development.

Quick start

Call the snippet where the form goes. The call must be uncached ([[!FetchIt]]): every output of the form has a token of its own, and the script is added only when the snippet ran in the request.

[[!FetchIt?
  &form=`myForm.tpl`
  &hooks=`email`
  &emailTo=`info@example.com`
  &emailSubject=`A request from the site`
  &validate=`name:required,email:email:required`
  &successMessage=`Your message has been sent`
]]

The chunk myForm.tpl, a plain form with the attributes of FetchIt:

<form action="[[~[[*id]]]]" method="post">
  <label>Name
    <input type="text" name="name" value="[[+fi.name]]">
    <span data-error="name">[[+fi.error.name]]</span>
  </label>
  <label>Email
    <input type="email" name="email" value="[[+fi.email]]">
    <span data-error="email">[[+fi.error.email]]</span>
  </label>
  <button type="submit">Send</button>

  <div data-success style="display: [[+fi.success:is=`1`:then=``:else=`none`]]">[[+fi.successMessage]]</div>
  <div data-validation-error style="display: [[+fi.validation_error:is=`1`:then=``:else=`none`]]">[[+fi.validation_error_message]]</div>
</form>
  • data-error="name" gets the error of the field name, and the field itself gets aria-invalid and the class from fetchit.frontend.input.invalid.class.
  • data-custom="name" (optional) gets the class from fetchit.frontend.custom.invalid.class: the wrapper of the field, for example.
  • data-success and data-validation-error show the message of the form.
  • The [[+fi.…]] placeholders are for submissions without JavaScript. If those do not matter, leave them out.

FetchIt adds the data-fetchit key, the config of the form (data-fetchit-config) and the service fields of the protection to the form, and adds the script to the page. The chunk tpl.FetchIt.example is a ready example.

The documentation (in Russian) has more examples: markup, notifications, modals, client-side validation, the JS API.

Snippet properties

Property Default What it does
form tpl.FetchIt.example The chunk of the form. With pdoTools: @FILE, @INLINE and Fenom
snippet FormIt The snippet that processes the form, see below
actionUrl [[+assetsUrl]]action.php Where the form is sent
clearFieldsOnSuccess 1 Clear the fields after a successful submission
formState 1 Put the state of the submission into data-fetchit-state of the form
ariaBusy 1 Put aria-busy="true" on the form while it is sent
redirectTo Where to go after a successful submission: a resource id or an address. For FormIt it is the property of its redirect hook, see Redirect after a submission
redirectParams The parameters of the redirectTo address, a JSON object
timeout 0 How many seconds to wait for the answer to one attempt; 0 means no limit
retries 0 How many times to send the form again after a network error, a timeout, a 502/503/504 of a proxy or while the form is still processed, 0 to 5, see Reliable submission

All other properties go to the processing snippet: for FormIt, &hooks, &validate, &emailTo, &successMessage, &placeholderPrefix and the rest.

The FetchItScript snippet has no properties: [[!FetchItScript]] adds the script to a page whose forms come later, see Things to keep in mind.

A snippet of your own gets the fields sent in $fields (without the service fields of the protection) and answers through FetchIt:

$FetchIt = FetchIt::service($modx);
if (empty($fields)) {
    // The form is being output, not sent: the snippet runs on every output too.
    return '';
}
if (empty($fields['email'])) {
    return $FetchIt->error('Please check the form', ['email' => 'Enter your email']);
}
// ... save it, send an email
return $FetchIt->success('Thank you, we got your request');

The empty($fields) check is a must: the snippet also runs on every output of the form, with an empty $fields, and without the check it would, for example, send an empty email on every page view. A form sent without JavaScript does not show what the snippet returns: a message appears on the page only if the snippet sets placeholders itself.

Redirect after a submission

After a successful submission FetchIt can take the visitor to another page:

  • FormIt: the redirect hook and &redirectTo work as usual: [[!FetchIt? &hooks=email,redirect &redirectTo=15]]. Before, such a form showed "could not send" though the email went out.
  • A snippet of your own: &redirectTo (a resource id or an address) and &redirectParams (a JSON object with the parameters of the address) in the FetchIt call; the server adds redirect to a successful answer. Your snippet may also call $modx->sendRedirect() itself: like the redirect hook, it reaches the script as a successful answer with the address, not as a redirect to HTML.

The script goes there after the fetchit:success event; the address is in event.detail.redirect (not in event.detail.response), and cancelling the event keeps the visitor on the page. It goes through the instance method navigate(url), which you can override for a router of your own. The script goes only to http: and https: addresses, resolves relative ones against the page, and does not count a change of only the hash of the same page as leaving: the form is given back. While the page is being left, the form stays disabled; if the visitor comes back with the Back button and the browser restores the page from its cache, the form is given back. navigate() must return false for a target that does not leave the page (a file download with Content-Disposition: attachment, a 204): otherwise the form stays disabled. A site with such a target overrides navigate(), for example to download through a hidden iframe:

const instance = FetchIt.instances.get(form);
const navigate = instance.navigate.bind(instance);
instance.navigate = (url) => {
    if (!url.includes('/download/')) return navigate(url);
    const frame = document.createElement('iframe');
    frame.hidden = true;
    frame.src = url;
    document.body.appendChild(frame);
    return false;
};

Without JavaScript a snippet of your own with &redirectTo shows its answer on the same page.

Reliable submission

On a poor connection &timeout limits the wait for the answer to one attempt (reading its body included), and &retries sends the form again after a network error, a timeout or a 502/503/504 of a proxy that is not a FetchIt answer:

[[!FetchIt? &form=`myForm` &timeout=`15` &retries=`2`]]

A retry does not process the form twice, except when the protection store cannot be written (the cause is logged): then the form is processed without the key. Each submission carries a key (the X-FetchIt-Submission header), and the server keeps the answer to it for an hour in the protection store (fetchit.protection.store: files in core/cache/fetchit/answers/ or the fetchit_store table). If the first attempt reached the server and the answer was lost, the retry gets the same answer. If the first one is still being processed, the retry is refused with HTTP 409 and X-FetchIt-Refused: pending (the message fetchit_err_pending) and waits. The pauses between attempts are 1, 2 and 4 seconds, then 8 seconds each. A FetchIt answer and other errors are not retried. The cancelable event fetchit:retry (detail has attempt, the number of the retry from 1, and error) comes before each retry; a destroyed form sends no retry.

If the last attempt timed out, the visitor sees fetchit_err_timeout: the form may have been sent. When the visitor sends the form again with the same data after a message that it got no answer (the timeout, a network error, fetchit_err_pending), it goes with the same key, so a slow server does not process it twice; a changed or reset form is a new submission with a new key. timeout and retries are off by default, and forms are sent as before.

Spam protection

Every FetchIt form is protected with no setup. The checks run before FormIt or your snippet, both for FetchIt submissions and for forms sent without JavaScript:

  • Token. The form gets a hidden fetchit_token field with a signature of the key of the form, the time it was output and a random number. A token is used once and needs no session. A new token is given only in answer to a well-signed token of the same form, so a bot cannot send the form without loading the page. The answer to a submission with a well-signed token brings the next token (except a refusal for the limit), so the form can be sent again without a reload.
  • Fill time. A form sent sooner than fetchit.protection.min_time seconds after the page was output (3 by default) is refused. A second submission counts from the same output, so a person who fixed a field after an error is not refused.
  • Trap. A hidden field with a random name per installation, which people do not see and browser autofill does not recognise. When it is filled in, the bot gets a success answer with the successMessage of the form, but the form is not processed and no email goes out. Such cases are logged.
  • Limit. At most fetchit.protection.rate_limit submissions of a form from one address within fetchit.protection.rate_window seconds (10 in 10 minutes by default). Every attempt with a well-signed token counts, refused ones too, so replaying an old token does not get around the limit.

The service fields are removed from $_POST before FormIt runs and never reach emails. The signing key is generated when the package is installed (fetchit.protection.secret).

Proof of work and captcha

Both are off by default and help when spam gets through the main checks. They work only with the protection on (fetchit.protection).

Proof of work. fetchit.protection.pow sets the difficulty in bits: 0 turns it off, 24 is the most (a larger value or one that is not a number is logged and replaced). Before sending, the browser looks for a number n such that SHA-256 of token:n starts with that many zero bits, and sends it in the fetchit_pow field. Solving starts as soon as the visitor enters the form, so the solution is usually ready by the time they press the button, while every submission costs a bot processor time. 16 bits take about 65 thousand hashes on average: about a third of a second on a computer, several times longer on a phone. Every bit doubles the time, 20 bits take about 16 times longer. When a page from a cache asks for less than the server, FetchIt solves again and sends the form once more by itself. With a proof of work on, a form cannot be sent without JavaScript.

Captcha. Set fetchit.captcha to turnstile (Cloudflare Turnstile), recaptcha (Google reCAPTCHA v3) or smartcaptcha (Yandex SmartCaptcha), and fetchit.captcha.site_key and fetchit.captcha.secret_key to the keys from the provider's dashboard. Until both keys are set, the captcha stays off and the log says what is missing (the same for a misspelt provider). FetchIt adds the provider's script and gets its answer before sending:

  • Turnstile shows its widget in a .fetchit-captcha block before the first button with type="submit", or at the end of the form when there is none;
  • reCAPTCHA v3 has no widget: an answer is asked for each submission, with the action fetchit. Answers scoring below fetchit.captcha.min_score (0.5 by default) are refused;
  • SmartCaptcha works invisibly and shows a puzzle only when in doubt.

The server checks the answer last among the checks of the protection (OnFetchItBeforeProcess plugins run after it), so bots without a token never reach the provider. When the provider refuses the answer, the visitor sees fetchit_err_captcha. When the provider does not answer, answers with an error or does not accept the secret key, the form is refused too, but with fetchit_err_captcha_unavailable ("the check is unavailable right now"), and the cause is logged. When the provider's script did not load (an ad blocker, CSP), the visitor closed the puzzle or the widget failed, the form is not sent and the visitor sees fetchit_err_captcha_client. The captcha works only through FetchIt: a form sent without JavaScript does not pass it.

For a development site Turnstile has test keys: site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA.

What the visitor sees

A refusal comes as an ordinary error of the form: the lexicon message fetchit_err_token (the form has expired), fetchit_err_too_fast, fetchit_err_rate, fetchit_err_store, fetchit_err_pow, fetchit_err_captcha or fetchit_err_captcha_unavailable, the fetchit:error event and a notification. fetchit_err_pending and fetchit_err_timeout are not refusals: they tell about a retry, see Reliable submission. When the token of the page is stale (a page from a cache, a page open for long, a new key), FetchIt sends the form once more with a new token by itself, and the visitor notices nothing. Without JavaScript the message goes to [[+fi.validation_error_message]], and the values entered are kept, as in the tpl.FetchIt.example chunk.

Things to keep in mind

  • A full-page cache (nginx, a CDN, static cache plugins). By default, pages with forms must be left out of such a cache: the properties of a form are kept in the session of the visitor the page was output for, and a visitor who gets the page from the cache sees "form not found". Turn on fetchit.page_cache and forms work on pages from the cache: the properties of the forms are kept in the protection store for 90 days after the page was last rendered (not served from the cache), and on the first focus on a form, or before the first submission if there was none, the script exchanges the token of the page for a fresh one with one light request, so the form goes with an unused token and is not refused and resent; exchanges have a rate limit of their own. After the exchange the script waits fetchit.protection.min_time plus a quarter of a second before it sends the form, so on several servers keep their clocks synchronised (NTP). After turning it on, clear the page cache and keep pages in it for less than 90 days. A page from the cache carries one token for all its visitors: without JavaScript only the first submission with it passes (until fetchit.protection.token_ttl runs out), the rest are refused. The token of any page that was ever cached can be exchanged until fetchit.protection.secret is changed; changing the key revokes all tokens. When the properties of a form depend on the request (for example [[!+get.x]]), each combination takes a record of its own.
  • FormIt 5.2 and later can send forms over AJAX itself. For FetchIt forms that mode is turned off: FetchIt removes the properties FormIt stored, the fi.ajaxToken placeholder and the formit.js script, as otherwise the form could be sent through FormIt's action.php, around the protection. Forms that [[!FormIt]] itself outputs on the same page keep their AJAX mode. The reCAPTCHA of FormIt 5.2 gets its answer through formit.js, so it does not work in FetchIt forms: use fetchit.captcha instead. Captcha hooks that read the answer from their field themselves work, as long as fetchit.captcha is not set to the same provider.
  • Forms loaded later (a modal loaded over AJAX, pdoPage, mFilter2) work like any other: every form carries its config in data-fetchit-config, and the script picks up inserted forms and forgets removed ones. The HTML must come from an uncached [[!FetchIt]] call, so the form has its token. A page that has no FetchIt form of its own and gets one later needs the script: put [[!FetchItScript]] into its template. A cached [[FetchItScript]] works too: it leaves the marker <!--FetchItScript--> in the cached page, and the plugin adds the script on every view and takes the marker out. Put the call into the markup of the page, not into an attribute or behind an output filter that escapes it; a plugin that strips HTML comments must run after FetchIt's, which runs at priority -10. A form inserted by your own script is picked up a moment later; call FetchIt.init(element) to have it at once. The form is processed in the context of the resource that output it: for a fragment, the fragment's page (pageId).
  • A script of your own instead of the bundled one must send the form to action.php with an X-FetchIt-Action header equal to the data-fetchit attribute of the form, and a pageId field with the id of the page. It must send the fetchit_token field of the form and take the next token from the X-FetchIt-Token header of every answer. When an answer comes with X-FetchIt-Refused: token, send the form again with the new token. With a proof of work, send in fetchit_pow a solution for the token sent in that request, and solve again for a resend. A pow refusal tells the difficulty in the X-FetchIt-Pow header. With a captcha, send the provider's answer in its field (cf-turnstile-response, g-recaptcha-response or smart-token), and execute reCAPTCHA v3 with the action fetchit. With fetchit.page_cache, exchange the token before the first submission (best on the first focus, so the fill time passes while the visitor types): send a POST to action.php with the headers X-FetchIt-Action and X-FetchIt-Exchange: 1 and the fields fetchit_token and pageId: the answer is 204 with the fresh token in X-FetchIt-Token, the difficulty in X-FetchIt-Pow and the fill time in seconds in X-FetchIt-Min-Time, or with X-FetchIt-Refused: token, rate or store and no token (then keep the token of the page); without fetchit.page_cache the answer is 204 with neither a token nor a refusal. Send the form with the fresh token only after the fill time has passed since the exchange. A token refusal may also carry X-FetchIt-Min-Time: wait that long before the resend. Exchanges have a rate limit of their own, with the same fetchit.protection.rate_limit and fetchit.protection.rate_window.
  • Behind a proxy or a CDN all visitors come from one address, and the limit becomes one for the whole site. List the addresses of the proxies in fetchit.protection.proxies (IPs or CIDR ranges, separated by commas) and the header with the visitor's address in fetchit.protection.ip_header (X-Forwarded-For, CF-Connecting-IP).
  • The state of the protection (used tokens, rate counters, the forms of fetchit.page_cache, the answers kept for retries for an hour) is kept in files by default: the marks in core/cache/fetchit/tokens/, the forms in core/cache/fetchit/forms/, the answers in core/cache/fetchit/answers/, which "Clear cache" leaves alone; the rate counters are in the MODX cache, which "Clear cache" resets. When the directory is not writable, forms are refused and the cause is logged. On several servers set fetchit.protection.store to db: all of it moves to the fetchit_store table of the site's database (the installer creates it; it works on MySQL, MariaDB and SQL Server), and the clocks of the servers must be in sync. Switching does not carry the marks, the forms and the answers over: a token used before the switch can be sent once more within token_ttl, a retry of a submission made before the switch is processed again, and forms on pages from the old cache work after the page is rendered again, so switch at a quiet time and then clear the page cache.
  • The log: fetchit.protection.log 0 turns it off, 1 (the default) logs problems and refusals that may hit people (the trap, the limit, writing the marks, a captcha provider that is unavailable), 2 logs every refusal, answers the captcha provider refused included. Mistakes in the settings of the captcha and the proof of work, and the problems of the fetchit_store table are always logged.
  • On a development site and in automated tests, set fetchit.protection.min_time and fetchit.protection.rate_limit to 0. Turn the whole protection (fetchit.protection) off only for debugging.

Rules of your own

A plugin on the OnFetchItBeforeProcess event gets $action, $fields (the fields sent, without the service fields and files), $properties (may be null) and $FetchIt. To refuse a submission, it outputs a message for the visitor or a lexicon key through $modx->event->output(). Returning a string with return does not refuse it: MODX only logs it.

// A plugin on OnFetchItBeforeProcess
$email = isset($fields['email']) && is_string($fields['email']) ? $fields['email'] : '';
if (preg_match('/@(mailinator|tempmail)\./i', $email)) {
    $modx->event->output('Disposable addresses are not accepted.');
}

The event also fires with the protection off.

Notifications

The built-in notifications of FetchIt: an error and a successful submission

With fetchit.frontend.default.notifier, the answers of the server and failed submissions are also shown as notifications in a corner of the page. The message is shown as text: tags are removed from it, HTML entities such as &amp; stay as they are.

  • Screen readers hear the notifications from two hidden live regions that are on the page from the start: an error at once (role="alert"), a success when the reader is idle (role="status").
  • A notification closes with its button or by itself after 6 seconds. While the pointer or the focus is on it, the countdown stops, and then starts over. When a notification is closed from the keyboard, the focus moves to the next notification or back to where it came from.
  • At most three are shown at a time. The one with the focus is never the one dropped.

The styles come first in <head>, with single class selectors. They win over the site's rules for bare elements (button { … }) and lose to any rule of the site with a class. Rules inside @layer (in Tailwind 4, for example) lose to them: for those, change the variables. By default they are pastel colours of Tailwind CSS 4, as in its alerts: the 100 shade for the background, 200 for the border, 800 for the text (a text contrast of about 6.5:1, well above WCAG AA). Where the browser knows oklch() they are exact, elsewhere in hex:

.fetchit-toasts {
  --fetchit-toast-success-bg: oklch(96.2% 0.044 156.743);     /* green-100, #dcfce7 */
  --fetchit-toast-success-border: oklch(92.5% 0.084 155.995); /* green-200, #b9f8cf */
  --fetchit-toast-success-text: oklch(44.8% 0.119 151.328);   /* green-800, #016630 */
  --fetchit-toast-error-bg: oklch(93.6% 0.032 17.717);        /* red-100, #ffe2e2 */
  --fetchit-toast-error-border: oklch(88.5% 0.062 18.334);    /* red-200, #ffc9c9 */
  --fetchit-toast-error-text: oklch(44.4% 0.177 26.899);      /* red-800, #9f0712 */
}

A site on Tailwind 4 can take its own variables, for example --fetchit-toast-success-bg: var(--color-emerald-100).

When the site's Content-Security-Policy forbids inline styles (style-src without 'unsafe-inline'), give the FetchIt script a nonce: the styles get the same one. Otherwise FetchIt warns in the console, and the notifications need styles of your own.

A FetchIt.Message of your own wins over the setting. When it has neither success nor error (only a spinner in before and after, for example), the built-in notifications add them, provided FetchIt.Message is set before DOMContentLoaded: right in a deferred script added after fetchit.js. You can also turn the notifications on from your own script, with another label for the button or another time on screen (0 means until closed): FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Close', duration: 4000 }).

Before FetchIt 4 the setting loaded the Notyf library. It is gone: styles for .notyf__toast and scripts that call new Notyf() need to change, or load Notyf themselves.

Events and JavaScript

The events are dispatched on document and do not bubble. Their event.detail holds the form (form), its data (formData) and the FetchIt instance (fetchit), and from fetchit:after on the answer of the server (response). A fetchit:error without an answer has the cause (error); fetchit:reset has only form and fetchit.

Event When Cancelling it (event.preventDefault())
fetchit:before before sending; fields can be added to formData the form is not sent
fetchit:after a FetchIt answer came the answer is not handled: no field errors, no message, no notification, no fetchit:success or fetchit:error, no clearing
fetchit:success the form was accepted; the address to go to, if any, is in detail.redirect the fields are not cleared, no redirect
fetchit:retry before a retry of the submission (&retries); detail also has attempt (the number of the retry, from 1) and error the retry is not sent, the visitor sees the error
fetchit:error the form was refused or could not be sent; response is null when no FetchIt answer came, the cause is in error the field errors and the message of the form are not shown
fetchit:reset the form is being reset: its button, form.reset() or after a success; the fields still hold their values not possible
document.addEventListener('fetchit:before', ({ detail }) => {
  detail.formData.append('page', location.pathname)
})

document.addEventListener('fetchit:success', ({ detail }) => {
  gtag('event', 'form_submit', { form_id: detail.form.id })
})

FetchIt.Message takes the hooks before, after(message), success(message), error(message) and reset. They run before the event of the same moment (except reset, which runs after fetchit:reset), so cancelling the event does not undo them:

FetchIt.Message = {
  success (message) { Swal.fire({ icon: 'success', text: message }) },
  error (message) { Swal.fire({ icon: 'error', text: message }) },
}

The global FetchIt exists once the deferred fetchit.js has run, so set FetchIt.Message from a deferred script added after it, or on DOMContentLoaded. The instance of a form is FetchIt.instances.get(form), with setError(name, message), clearErrors(), setFormMessage(type, message), navigate(url) and other methods.

The page starts FetchIt with FetchIt.observe() on DOMContentLoaded. It handles the forms on the page and those inserted later, and calls destroy() on forms taken out of the page: the instance leaves FetchIt.instances, its proof of work stops, its captcha widget is removed. FetchIt.init(root) handles the forms in root right away, with the class that observes the page. A form whose config is broken or that cannot start is logged in the console and sent the usual way; the other forms start all the same. A form removed while it is being sent ends quietly, without an error message. FetchIt.create(config) still works for pages cached by FetchIt 4.0. A class of your own in fetchit.frontend.js.classname with a create() of its own gets a create(config) call per form key first, as in FetchIt 4.0, and then observe(); a script of your own without observe() gets only the create() calls, for the forms on the page when it loads.

The state of the form

While a form is sent, it has data-fetchit-state="sending" and aria-busy="true"; after the answer, data-fetchit-state is success or error (a refusal of the server or a failed request), and aria-busy is gone. The state is set before fetchit:after, fetchit:error and fetchit:success, so it holds when a listener cancels them. A reset of the form removes it, except while the form is sent; the reset of clearFieldsOnSuccess keeps success. Styles need only CSS:

form[data-fetchit-state="sending"] button[type="submit"] {
  cursor: progress;
  opacity: .6;
}

&formState=0 and &ariaBusy=0 turn them off for a form. CSS that draws a spinner on any element with [aria-busy="true"] (Pico CSS 1 and 2.0, for example; Pico 2.1 leaves forms out) draws it over the whole form: turn &ariaBusy off there if that is not wanted.

TypeScript

The types are in assets/components/fetchit/js/fetchit.d.ts. Copy the file into your project or reference it:

/// <reference path="../assets/components/fetchit/js/fetchit.d.ts" />

document.addEventListener('fetchit:error', event => {
  // response is null when no FetchIt answer came: the network, a foreign answer, a captcha without an answer
  if (event.detail.response === null) {
    console.error(event.detail.error)
  }
})

const form = document.querySelector('form')
if (form) {
  FetchIt.instances.get(form)?.setError('email', 'Check the address')
}

The script is type-checked against the same file: FetchIt with the instances of forms, and the detail of every event, so the types and the code cannot drift apart. Remove your own declaration of FetchIt if you had one (declare var FetchIt: any): the two would clash.

System settings

All settings are in the fetchit namespace.

Setting Default What it does
fetchit.frontend.js [[+assetsUrl]]js/fetchit.js The FetchIt script. The minified js/fetchit.min.js is about 19 KB instead of 36 (about 7 KB instead of 10 gzipped)
fetchit.frontend.js.classname FetchIt The class that handles the forms, when you extended the bundled one
fetchit.frontend.input.invalid.class is-invalid The class of an invalid field
fetchit.frontend.custom.invalid.class The class for the data-custom elements of an invalid field
fetchit.frontend.default.notifier No Show the answers as built-in notifications
fetchit.protection Yes Spam protection. Turn it off only for debugging
fetchit.protection.secret random The key that signs the tokens, generated at install
fetchit.protection.min_time 3 The minimum fill time, seconds; 0 turns it off
fetchit.protection.token_ttl 86400 How many seconds a page with a form stays valid; 0 and anything above 30 days mean 30 days
fetchit.protection.rate_limit 10 Submissions of a form from one address within the window; 0 turns it off
fetchit.protection.rate_window 600 The window of the limit, seconds; 0 turns the limit off
fetchit.protection.log 1 The log of the protection: 0 nothing, 1 problems, 2 every refusal
fetchit.protection.proxies Trusted proxies or CDNs, IPs or CIDR ranges separated by commas
fetchit.protection.ip_header X-Forwarded-For The header with the visitor's address from a trusted proxy
fetchit.protection.pow 0 The difficulty of the proof of work, bits; 0 turns it off, 24 at most
fetchit.page_cache No A full-page cache serves the pages with forms, see above
fetchit.protection.store files Where the state of the protection is kept. files: used tokens, the forms of fetchit.page_cache and the answers kept for retries (1 hour) in core/cache/fetchit/ (kept on "Clear cache"), rate counters in the MODX cache (reset by it); one server. db: all of it in the table fetchit_store, shared by several servers with synchronised clocks. Empty means files; any other value refuses the forms and is logged
fetchit.captcha turnstile, recaptcha or smartcaptcha; empty for none
fetchit.captcha.site_key The site key from the captcha provider
fetchit.captcha.secret_key The secret key from the captcha provider
fetchit.captcha.min_score 0.5 reCAPTCHA v3: the minimum score, from 0 to 1

Upgrading to FetchIt 4

FetchIt 4 is one package for MODX 2.8 and MODX 3 instead of the 1.x and 3.x lines. Install it over the version you have with the Package Manager: the system settings and chunks stay as they are. The FetchIt snippet and plugin are replaced, together with the default properties of the snippet, so changes to their code and properties are lost; the snippet calls on your pages need no changes. CI checks the upgrade from 1.1.3 on MODX 2 and from 3.1.4 on MODX 3.

The API of the earlier versions works on both MODX versions:

  • in your snippets, get FetchIt with $FetchIt = FetchIt::service($modx);. The 1.x call ($modx->getService('fetchit', 'FetchIt', MODX_CORE_PATH . 'components/fetchit/model/')) and the 3.x one ($modx->services->get('FetchIt'), MODX 3 only) return the same object;
  • the 3.x class FetchIt\FetchIt is the same class, instanceof \FetchIt\FetchIt works. get_class() now returns FetchIt;
  • storeActionProperties()/loadActionProperties() and their 3.x names saveActionProperties()/getActionProperties();
  • chunks through pdoTools (Fenom, @FILE) on MODX 2 and MODX 3.

What may affect your code:

  • forms get the service fields of the protection; a script of your own instead of the bundled one must send the token (see Things to keep in mind);
  • the notifications setting no longer loads Notyf (see Notifications);
  • fetchit:error also fires when a request fails; detail.response is then null, and the error is in detail.error;
  • the processing snippet gets only the form sent in fields: $_POST and the files from $_FILES when sent through FetchIt, only $_POST for a form sent the usual way; no GET values and no cookies;
  • fetchit:success can be cancelled: event.preventDefault() keeps the fields filled;
  • sending again while a request is running is ignored;
  • method and data-fetchit now come last among the attributes of the form tag.

The full list of changes is in the changelog.

Requirements

  • MODX 2.8 or MODX 3 (checked on 2.8.6 and 3.2.4).
  • PHP 7.4 or later. The syntax and PHPUnit are checked on PHP 7.4 to 8.4, the install and form submissions on MODX on PHP 7.4 and 8.3.
  • FormIt, when it processes the forms; it is installed with FetchIt.
  • pdoTools, for form chunks on Fenom or in files.
  • The browsers of browserslist in package.json (current versions of every major browser): the script is built with ES2019 syntax, and CI checks it. Without JavaScript, forms with FormIt work the usual way, unless a proof of work or a captcha is on.

Development

Environment

You need Node.js 22.22+, 24.15+ or 26+ (the CI version is in .node-version), PHP 7.4+ with Composer, and Docker.

npm ci && composer install

npm run build        # src/ → assets/components/fetchit/js/: the ES2019 script and fetchit.d.ts
npm run check:syntax # the built script parses as ES2019
npm run lint         # oxlint
npm run typecheck    # tsc: the code, and the built public types as a site sees them (tests/types, after npm run build)
npm test             # Vitest
vendor/bin/phpunit   # PHPUnit
npm run screenshots  # screenshots of the notifications in docs/images/, after changing their styles
npm run logo         # the logo, the square mark, the pictures for modstore.pro and extras.modx.com and the social previews in .github/logo/ (needs the network for Google Fonts)

Local sites

Two sites, MODX 2.8.6 on PHP 7.4 and MODX 3.2.4 on PHP 8.3. The component directories of the repository are mounted in both, so changes to PHP and the built JS show at once. Changes in src/ show after npm run build.

# if your uid/gid are not 1000: export HOST_UID=$(id -u) HOST_GID=$(id -g)
docker compose up -d --build
docker compose logs -f modx2 modx3   # wait for "[fetchit] Manager: …"
# MODX 2: http://localhost:8052/, MODX 3: http://localhost:8053/ (admin / FetchItDev2026)

The package is built on MODX 2 and installed on both sites the same way CI does it:

docker compose exec -u www-data -e PKG_DIST=1 modx2 php /extra/_build/build.php
docker compose exec -u www-data modx2 php /extra/_build/ci/install.php /extra/_packages/fetchit-<version>-<release>.transport.zip
docker compose exec -u www-data modx3 php /extra/_build/ci/install.php /extra/_packages/fetchit-<version>-<release>.transport.zip

Without PKG_DIST=1, build.php also installs the package on the site it is built on. To check upgrades from earlier versions there are clean sites without the directories of the repository, see compose.clean.yml.

HTTP checks and browser tests on a local site:

fixtures=$(docker compose exec -T -u www-data modx2 php /extra/_build/ci/fixtures.php)
_build/ci/smoke.sh http://localhost:8052 "$fixtures"
npx playwright install chromium
BASE_URL=http://localhost:8052 FIXTURES="$fixtures" npm run e2e

How it works

  • The snippet (core/components/fetchit/elements/snippets/) outputs the chunk of the form, gives it the data-fetchit key (a hash of the call's properties), its config in data-fetchit-config and the service fields of the protection, stores its properties under that key, and processes a form sent without JavaScript itself.
  • FetchItScript asks for the script on a page whose forms come later, and leaves the <!--FetchItScript--> marker, which a cached call keeps in the cached page.
  • The plugin on OnWebPagePrerender adds the script and the bootstrap that calls FetchIt.observe() to <head> when either snippet ran in the request or the page has the marker of FetchItScript, which it takes out. It runs at priority -10, before plugins that minify the page. A fragment without <head> and <body> gets nothing.
  • The script (src/, built into assets/components/fetchit/js/) picks up the forms with their config, also those inserted later, sends the form to action.php with an X-FetchIt-Action header, solves the proof of work, gets the captcha's answer, shows the errors and messages, and dispatches the events.
  • action.php finds the properties of the form by its key, checks the protection and runs FormIt or your snippet. The answer to a submission with an X-FetchIt-Submission header is kept for an hour, so a retry gets the same one; a redirect during processing becomes a successful answer with redirect.
  • FetchItGuard checks the token, the limit, the trap, the proof of work, the fill time and the captcha (FetchItCaptcha), then fires OnFetchItBeforeProcess.

Checks and releases

CI checks every PR: PHP syntax on 7.4 to 8.4, PHPUnit, the linter, the types, the tests, that the committed JS matches src/ and has ES2019 syntax, the workflows and shell scripts, and that the versions agree. Then it builds the package on MODX 2.8.6, installs it on MODX 2.8.6 and 3.2.4, over 1.1.3 and 3.1.4 too, and sends forms over HTTP (with anonymous sessions and without) and from a browser (Playwright): with the protection, a proof of work, Turnstile and the built-in notifications.

The database store is tested on SQLite in the usual PHPUnit run, and in CI on MySQL, MariaDB and SQL Server too. Locally, on the MySQL from compose.yml:

FETCHIT_TEST_DSN='mysql:host=127.0.0.1;port=13352;dbname=modx3' FETCHIT_TEST_USER=root FETCHIT_TEST_PASSWORD=root vendor/bin/phpunit --filter 'DbStoreTest|StoreResolverTest'

To release:

  1. Raise the version in _build/config.inc.php (and release when needed, alpha → pl for example) and core/components/fetchit/model/fetchit.class.php, and in package.json and package-lock.json with npm version X.Y.Z --no-git-tag-version.
  2. Add a ## [X.Y.Z] - YYYY-MM-DD section to core/components/fetchit/docs/changelog.txt.
  3. Merge it into master and push a tag: git tag vX.Y.Z && git push origin vX.Y.Z.

The workflow checks the version, builds the package, runs it on MODX 2 and 3, and only then creates the GitHub release with the package. The release notes come from the feat, fix, perf and refactor commits (conventional commits); when there are none, from the changelog section.

Branches

FetchIt 4 is developed in master. The earlier lines are no longer developed: 1.x for MODX 2 (last release 1.1.3) stays in the history of master, 3.x for MODX 3 (last release 3.1.4) in the next branch.

License

GPL-2.0.

About

Формы на MODX без перезагрузки: Fetch API, FormIt, защита от спама. Один пакет для MODX 2.8 и 3.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages