(Grav GitSync) Automatic Commit from smokephil

This commit is contained in:
smokephil 2025-11-11 19:27:29 +01:00 committed by GitSync
parent d73d0ba519
commit 96a01e3ab4
260 changed files with 25905 additions and 16011 deletions

View file

@ -3,3 +3,7 @@
/.idea
node_modules
*.js.map
/repomix-output.md
/repomix.config.json
/.repomixignore
/grav-form-plugin.md

File diff suppressed because it is too large Load diff

View file

@ -106,8 +106,18 @@ export default class FilesField {
global.location.reload();
}
if (response && response.status === 'error') {
return this.handleError({
file,
data: response,
mode: 'removeFile',
msg: `<p>${translations.PLUGIN_FORM.FILE_ERROR_UPLOAD} <strong>${file.name}</strong></p>
<pre>${response.message}</pre>`
});
}
// store params for removing file from session before it gets saved
if (response.session) {
if (response && response.session) {
file.sessionParams = response.session;
file.removeUrl = this.options.url;
@ -117,13 +127,7 @@ export default class FilesField {
input.val(value + ' ');
}
return this.handleError({
file,
data: response,
mode: 'removeFile',
msg: `<p>${translations.PLUGIN_FORM.FILE_ERROR_UPLOAD} <strong>${file.name}</strong></p>
<pre>${response.message}</pre>`
});
return true;
}
onDropzoneComplete(file) {
@ -204,34 +208,49 @@ export default class FilesField {
}
handleError(options) {
return true;
/* let { file, data, mode, msg } = options;
if (data.status !== 'error' && data.status !== 'unauthorized') { return; }
const { file, data, msg } = options;
const status = data && data.status;
switch (mode) {
case 'addBack':
if (file instanceof File) {
this.dropzone.addFile.call(this.dropzone, file);
} else {
this.dropzone.files.push(file);
this.dropzone.options.addedfile.call(this.dropzone, file);
this.dropzone.options.thumbnail.call(this.dropzone, file, file.extras.url);
}
break;
case 'removeFile':
default:
if (~this.dropzone.files.indexOf(file)) {
file.rejected = true;
this.dropzone.removeFile.call(this.dropzone, file, { silent: true });
}
break;
if (status !== 'error' && status !== 'unauthorized') {
return false;
}
let modal = $('[data-remodal-id="generic"]');
modal.find('.error-content').html(msg);
$.remodal.lookup[modal.data('remodal')].open(); */
const message = data && data.message ? data.message : (msg || translations.PLUGIN_FORM.FILEPOND_ERROR_FILESIZE);
if (file && this.dropzone) {
file.accepted = false;
file.status = Dropzone.ERROR;
file.rejected = true;
const preview = $(file.previewElement);
if (preview.length) {
preview.addClass('dz-error');
preview.find('[data-dz-errormessage]').html(message);
}
// Remove the errored file so the user can try again.
if (~this.dropzone.files.indexOf(file)) {
setTimeout(() => {
this.dropzone.removeFile.call(this.dropzone, file, { silent: true });
this.dropzone._updateMaxFilesReachedClass();
}, 100);
}
}
const field = this.container.closest('.form-field');
if (field.length) {
let errorBox = field.find('.form-errors');
if (!errorBox.length) {
errorBox = $('<div class="form-errors"></div>').appendTo(field);
}
errorBox.html(`<p class="form-message"><i class="fa fa-exclamation-circle"></i> ${message}</p>`);
} else if (typeof global.alert === 'function') {
// Fall back to alert if no inline container is present.
global.alert(message);
}
return true;
}
}
@ -338,3 +357,12 @@ export let Instances = (() => {
return instances;
})();
// Expose addNode function to global scope for XHR reinitialization and pipeline compatibility
if (typeof window.GravForm === 'undefined') {
window.GravForm = {};
}
window.GravForm.FilesField = {
addNode,
instances: Instances
};

View file

@ -0,0 +1,128 @@
(function() {
'use strict';
// Function to refresh a captcha image
const refreshCaptchaImage = function(container) {
const img = container.querySelector('img');
if (!img) {
console.warn('Cannot find captcha image in container');
return;
}
// Get the base URL and field ID
const baseUrl = img.dataset.baseUrl || img.src.split('?')[0];
const fieldId = img.dataset.fieldId || container.dataset.fieldId;
// Force reload by adding/updating timestamp and field ID
const timestamp = new Date().getTime();
let newUrl = baseUrl + '?t=' + timestamp;
if (fieldId) {
newUrl += '&field=' + fieldId;
}
img.src = newUrl;
// Also clear the input field if we can find it
const formField = container.closest('.form-field');
if (formField) {
const input = formField.querySelector('input[type="text"]');
if (input) {
input.value = '';
// Try to focus the input
try { input.focus(); } catch(e) {}
}
}
};
// Function to set up click handlers for refresh buttons
const setupRefreshButtons = function() {
// Find all captcha containers
const containers = document.querySelectorAll('[data-captcha-provider="basic-captcha"]');
containers.forEach(function(container) {
// Find the refresh button within this container
const button = container.querySelector('button');
if (!button) {
return;
}
// Remove any existing listeners (just in case)
button.removeEventListener('click', handleRefreshClick);
// Add the click handler
button.addEventListener('click', handleRefreshClick);
});
};
// Click handler function
const handleRefreshClick = function(event) {
// Prevent default behavior and stop propagation
event.preventDefault();
event.stopPropagation();
// Find the container
const container = this.closest('[data-captcha-provider="basic-captcha"]');
if (!container) {
return false;
}
// Refresh the image
refreshCaptchaImage(container);
return false;
};
// Set up a mutation observer to handle dynamically added captchas
const setupMutationObserver = function() {
// Check if MutationObserver is available
if (typeof MutationObserver === 'undefined') return;
// Create a mutation observer to watch for new captcha elements
const observer = new MutationObserver(function(mutations) {
let needsSetup = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
// Check if any of the added nodes contain our captcha containers
for (let i = 0; i < mutation.addedNodes.length; i++) {
const node = mutation.addedNodes[i];
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this element has or contains captcha containers
if (node.querySelector && (
node.matches('[data-captcha-provider="basic-captcha"]') ||
node.querySelector('[data-captcha-provider="basic-captcha"]')
)) {
needsSetup = true;
break;
}
}
}
}
});
if (needsSetup) {
setupRefreshButtons();
}
});
// Start observing the document
observer.observe(document.body, {
childList: true,
subtree: true
});
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
setupRefreshButtons();
setupMutationObserver();
// Also connect to XHR system if available (for best of both worlds)
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('basic-captcha', {
reset: function(container, form) {
refreshCaptchaImage(container);
}
});
}
});
})();

View file

@ -0,0 +1,166 @@
(function() {
'use strict';
// Register the handler with the form system when it's ready
const registerRecaptchaHandler = function() {
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('recaptcha', {
reset: function(container, form) {
if (!form || !form.id) {
console.warn('Cannot reset reCAPTCHA: form is invalid or missing ID');
return;
}
const formId = form.id;
console.log(`Attempting to reset reCAPTCHA for form: ${formId}`);
// First try the expected ID pattern from the Twig template
const recaptchaId = `g-recaptcha-${formId}`;
// We need to look more flexibly for the container
let widgetContainer = document.getElementById(recaptchaId);
// If not found by ID, look for the div inside the captcha provider container
if (!widgetContainer) {
// Try to find it inside the captcha provider container
widgetContainer = container.querySelector('.g-recaptcha');
if (!widgetContainer) {
// If that fails, look more broadly in the form
widgetContainer = form.querySelector('.g-recaptcha');
if (!widgetContainer) {
// Last resort - create a new container if needed
console.warn(`reCAPTCHA container #${recaptchaId} not found. Creating a new one.`);
widgetContainer = document.createElement('div');
widgetContainer.id = recaptchaId;
widgetContainer.className = 'g-recaptcha';
container.appendChild(widgetContainer);
}
}
}
console.log(`Found reCAPTCHA container for form: ${formId}`);
// Get configuration from data attributes
const parentContainer = container.closest('[data-captcha-provider="recaptcha"]');
if (!parentContainer) {
console.warn('Cannot find reCAPTCHA parent container with data-captcha-provider attribute.');
return;
}
const sitekey = parentContainer.dataset.sitekey;
const version = parentContainer.dataset.version || '2-checkbox';
const isV3 = version.startsWith('3');
const isInvisible = version === '2-invisible';
if (!sitekey) {
console.warn('Cannot reinitialize reCAPTCHA - missing sitekey attribute');
return;
}
console.log(`Re-rendering reCAPTCHA widget for form: ${formId}, version: ${version}`);
// Handle V3 reCAPTCHA differently
if (isV3) {
try {
// For v3, we don't need to reset anything visible, just make sure we have the API
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function') {
// Create a new execution context for the form
const actionName = `form_${formId}`;
const tokenInput = form.querySelector('input[name="token"]') ||
form.querySelector('input[name="data[token]"]');
const actionInput = form.querySelector('input[name="action"]') ||
form.querySelector('input[name="data[action]"]');
if (tokenInput && actionInput) {
// Clear previous token
tokenInput.value = '';
// Set the action name
actionInput.value = actionName;
console.log(`reCAPTCHA v3 ready for execution on form: ${formId}`);
} else {
console.warn(`Cannot find token or action inputs for reCAPTCHA v3 in form: ${formId}`);
}
} else {
console.warn('reCAPTCHA v3 API not properly loaded.');
}
} catch (e) {
console.error(`Error setting up reCAPTCHA v3: ${e.message}`);
}
return;
}
// For v2, handle visible widget reset
// Clear the container to ensure fresh rendering
widgetContainer.innerHTML = '';
// Check if reCAPTCHA API is available
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.render === 'function') {
try {
// Render with a slight delay to ensure DOM is settled
setTimeout(() => {
grecaptcha.render(widgetContainer.id || widgetContainer, {
'sitekey': sitekey,
'theme': parentContainer.dataset.theme || 'light',
'size': isInvisible ? 'invisible' : 'normal',
'callback': function(token) {
console.log(`reCAPTCHA verification completed for form: ${formId}`);
// If it's invisible reCAPTCHA, submit the form automatically
if (isInvisible && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
}
}
});
console.log(`Successfully rendered reCAPTCHA for form: ${formId}`);
}, 100);
} catch (e) {
console.error(`Error rendering reCAPTCHA widget: ${e.message}`);
widgetContainer.innerHTML = '<p style="color:red;">Error initializing reCAPTCHA.</p>';
}
} else {
console.warn('reCAPTCHA API not available. Attempting to reload...');
// Remove existing script if any
const existingScript = document.querySelector('script[src*="google.com/recaptcha/api.js"]');
if (existingScript) {
existingScript.parentNode.removeChild(existingScript);
}
// Create new script element
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js${isV3 ? '?render=' + sitekey : ''}`;
script.async = true;
script.defer = true;
script.onload = function() {
console.log('reCAPTCHA API loaded, retrying widget render...');
setTimeout(() => {
const retryContainer = document.querySelector(`[data-captcha-provider="recaptcha"]`);
if (retryContainer && form) {
window.GravFormXHR.captcha.getProvider('recaptcha').reset(retryContainer, form);
}
}, 200);
};
document.head.appendChild(script);
}
}
});
console.log('reCAPTCHA XHR handler registered successfully');
} else {
console.error('GravFormXHR.captcha not found. Make sure the Form plugin is loaded correctly.');
}
};
// Try to register the handler immediately if GravFormXHR is already available
if (window.GravFormXHR && window.GravFormXHR.captcha) {
registerRecaptchaHandler();
} else {
// Otherwise, wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Give a small delay to ensure GravFormXHR is initialized
setTimeout(registerRecaptchaHandler, 100);
});
}
})();

View file

@ -0,0 +1,121 @@
(function() {
'use strict';
// Register the handler with the form system when it's ready
const registerTurnstileHandler = function() {
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('turnstile', {
reset: function(container, form) {
const formId = form.id;
const containerId = `cf-turnstile-${formId}`;
const widgetContainer = document.getElementById(containerId);
if (!widgetContainer) {
console.warn(`Turnstile container #${containerId} not found.`);
return;
}
// Get configuration from data attributes
const parentContainer = container.closest('[data-captcha-provider="turnstile"]');
const sitekey = parentContainer ? parentContainer.dataset.sitekey : null;
if (!sitekey) {
console.warn('Cannot reinitialize Turnstile - missing sitekey attribute');
return;
}
// Clear the container to ensure fresh rendering
widgetContainer.innerHTML = '';
console.log(`Re-rendering Turnstile widget for form: ${formId}`);
// Check if Turnstile API is available
if (typeof window.turnstile !== 'undefined') {
try {
// Reset any existing widgets
try {
window.turnstile.reset(containerId);
} catch (e) {
// Ignore reset errors, we'll re-render anyway
}
// Render with a slight delay to ensure DOM is settled
setTimeout(() => {
window.turnstile.render(`#${containerId}`, {
sitekey: sitekey,
theme: parentContainer ? (parentContainer.dataset.theme || 'light') : 'light',
callback: function(token) {
console.log(`Turnstile verification completed for form: ${formId} with token:`, token.substring(0, 10) + '...');
// Create or update hidden input for token
let tokenInput = form.querySelector('input[name="cf-turnstile-response"]');
if (!tokenInput) {
console.log('Creating new hidden input for Turnstile token');
tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'cf-turnstile-response';
form.appendChild(tokenInput);
} else {
console.log('Updating existing hidden input for Turnstile token');
}
tokenInput.value = token;
// Also add a debug attribute
form.setAttribute('data-turnstile-verified', 'true');
},
'expired-callback': function() {
console.log(`Turnstile token expired for form: ${formId}`);
},
'error-callback': function(error) {
console.error(`Turnstile error for form ${formId}: ${error}`);
}
});
}, 100);
} catch (e) {
console.error(`Error rendering Turnstile widget: ${e.message}`);
widgetContainer.innerHTML = '<p style="color:red;">Error initializing Turnstile.</p>';
}
} else {
console.warn('Turnstile API not available. Attempting to reload...');
// Remove existing script if any
const existingScript = document.querySelector('script[src*="challenges.cloudflare.com/turnstile/v0/api.js"]');
if (existingScript) {
existingScript.parentNode.removeChild(existingScript);
}
// Create new script element
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.onload = function() {
console.log('Turnstile API loaded, retrying widget render...');
setTimeout(() => {
const retryContainer = document.querySelector('[data-captcha-provider="turnstile"]');
if (retryContainer && form) {
window.GravFormXHR.captcha.getProvider('turnstile').reset(retryContainer, form);
}
}, 200);
};
document.head.appendChild(script);
}
}
});
console.log('Turnstile XHR handler registered successfully');
} else {
console.error('GravFormXHR.captcha not found. Make sure the Form plugin is loaded correctly.');
}
};
// Try to register the handler immediately if GravFormXHR is already available
if (window.GravFormXHR && window.GravFormXHR.captcha) {
registerTurnstileHandler();
} else {
// Otherwise, wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Give a small delay to ensure GravFormXHR is initialized
setTimeout(registerTurnstileHandler, 100);
});
}
})();

View file

@ -0,0 +1,311 @@
/**
* Direct Dropzone Initialization for XHR Forms
*
* This script directly targets Form plugin's Dropzone initialization mechanisms
*/
(function() {
'use strict';
// Enable debugging logs
const DEBUG = false;
// Helper function for logging
function log(message, type = 'log') {
if (!DEBUG) return;
const prefix = '[Dropzone Direct Init]';
if (type === 'error') {
console.error(prefix, message);
} else if (type === 'warn') {
console.warn(prefix, message);
} else {
console.log(prefix, message);
}
}
// Flag to prevent multiple initializations
let isInitializing = false;
// Function to directly initialize Dropzone
function initializeDropzone(element) {
if (isInitializing) {
log('Initialization already in progress, skipping');
return false;
}
if (!element || element.classList.contains('dz-clickable')) {
return false;
}
log('Starting direct Dropzone initialization for element:', element);
isInitializing = true;
// First, let's try to find the FilesField constructor in the global scope
if (typeof FilesField === 'function') {
log('Found FilesField constructor, trying direct instantiation');
try {
new FilesField({
container: element,
options: {}
});
log('Successfully initialized Dropzone using FilesField constructor');
isInitializing = false;
return true;
} catch (e) {
log(`Error using FilesField constructor: ${e.message}`, 'error');
// Continue with other methods
}
}
// Second approach: Look for the Form plugin's initialization code in the page
const dropzoneInit = findFunctionOnWindow('addNode') ||
window.addNode ||
findFunctionOnWindow('initDropzone');
if (dropzoneInit) {
log('Found Form plugin initialization function, calling it directly');
try {
dropzoneInit(element);
log('Successfully called Form plugin initialization function');
isInitializing = false;
return true;
} catch (e) {
log(`Error calling Form plugin initialization function: ${e.message}`, 'error');
// Continue with other methods
}
}
// Third approach: Try to invoke Dropzone directly if it's globally available
if (typeof Dropzone === 'function') {
log('Found global Dropzone constructor, trying direct instantiation');
try {
// Extract settings from the element
const settingsAttr = element.getAttribute('data-grav-file-settings');
if (!settingsAttr) {
log('No settings found for element', 'warn');
isInitializing = false;
return false;
}
const settings = JSON.parse(settingsAttr);
const optionsAttr = element.getAttribute('data-dropzone-options');
const options = optionsAttr ? JSON.parse(optionsAttr) : {};
// Configure Dropzone options
const dropzoneOptions = {
url: element.getAttribute('data-file-url-add') || window.location.href,
maxFiles: settings.limit || null,
maxFilesize: settings.filesize || 10,
acceptedFiles: settings.accept ? settings.accept.join(',') : null
};
// Merge with any provided options
Object.assign(dropzoneOptions, options);
// Create new Dropzone instance
new Dropzone(element, dropzoneOptions);
log('Successfully initialized Dropzone using global constructor');
isInitializing = false;
return true;
} catch (e) {
log(`Error using global Dropzone constructor: ${e.message}`, 'error');
// Continue to final approach
}
}
// Final approach: Force reloading of Form plugin's JavaScript
log('Attempting to force reload Form plugin JavaScript');
// Look for Form plugin's JS files
const formVendorScript = document.querySelector('script[src*="form.vendor.js"]');
const formScript = document.querySelector('script[src*="form.min.js"]');
if (formVendorScript || formScript) {
log('Found Form plugin scripts, attempting to reload them');
// Create new script elements
if (formVendorScript) {
const newVendorScript = document.createElement('script');
newVendorScript.src = formVendorScript.src.split('?')[0] + '?t=' + new Date().getTime();
newVendorScript.async = true;
newVendorScript.onload = function() {
log('Reloaded Form vendor script');
// Trigger event after script loads
setTimeout(function() {
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
}, 100);
};
document.head.appendChild(newVendorScript);
}
if (formScript) {
const newFormScript = document.createElement('script');
newFormScript.src = formScript.src.split('?')[0] + '?t=' + new Date().getTime();
newFormScript.async = true;
newFormScript.onload = function() {
log('Reloaded Form script');
// Trigger event after script loads
setTimeout(function() {
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
}, 100);
};
document.head.appendChild(newFormScript);
}
}
// As a final resort, trigger the mutation event
log('Triggering mutation._grav event as final resort');
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
isInitializing = false;
return false;
}
// Helper function to find a function on the window object by name pattern
function findFunctionOnWindow(pattern) {
for (const key in window) {
if (typeof window[key] === 'function' && key.includes(pattern)) {
return window[key];
}
}
return null;
}
// Function to check all Dropzone elements
function checkAllDropzones() {
const dropzones = document.querySelectorAll('.dropzone.files-upload:not(.dz-clickable)');
if (dropzones.length === 0) {
log('No uninitialized Dropzone elements found');
return;
}
log(`Found ${dropzones.length} uninitialized Dropzone elements`);
// Try to initialize each one
dropzones.forEach(function(element) {
initializeDropzone(element);
});
}
// Hook into form submission to reinitialize after XHR updates
function setupFormSubmissionHook() {
// First check if the XHR submit function is available
if (window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
log('Found GravFormXHR.submit, attaching hook');
// Store the original function
const originalSubmit = window.GravFormXHR.submit;
// Override it with our version
window.GravFormXHR.submit = function(form) {
log(`XHR form submission detected for form: ${form?.id || 'unknown'}`);
// Call the original function
const result = originalSubmit.apply(this, arguments);
// Set up checks for after the submission completes
[500, 1000, 2000, 3000].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
return result;
};
log('Successfully hooked into GravFormXHR.submit');
}
// Also add a direct event listener for standard form submissions
document.addEventListener('submit', function(event) {
if (event.target.tagName === 'FORM') {
log(`Standard form submission detected for form: ${event.target.id || 'unknown'}`);
// Schedule checks after submission
[1000, 2000, 3000].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
}
});
log('Form submission hooks set up');
}
// Monitor for AJAX responses
function setupAjaxMonitoring() {
if (window.jQuery) {
log('Setting up jQuery AJAX response monitoring');
jQuery(document).ajaxComplete(function(event, xhr, settings) {
log('AJAX request completed, checking if form-related');
// Check if this looks like a form request
const url = settings.url || '';
if (url.includes('form') ||
url.includes('task=') ||
url.includes('file-upload') ||
url.includes('file-uploader')) {
log('Form-related AJAX request detected, will check for Dropzones');
// Schedule checks with delays
[300, 800, 1500].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
}
});
log('jQuery AJAX monitoring set up');
}
}
// Create global function for manual reinitialization
window.reinitializeDropzones = function() {
log('Manual reinitialization triggered');
checkAllDropzones();
return 'Reinitialization check triggered. See console for details.';
};
// Main initialization function
function initialize() {
log('Initializing Dropzone direct initialization system');
// Set up submission hook
setupFormSubmissionHook();
// Set up AJAX monitoring
setupAjaxMonitoring();
// Do an initial check for any uninitialized Dropzones
setTimeout(checkAllDropzones, 500);
log('Initialization complete. Use window.reinitializeDropzones() for manual reinitialization.');
}
// Start when the DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// Delay to allow other scripts to load
setTimeout(initialize, 100);
});
} else {
// DOM already loaded, delay slightly
setTimeout(initialize, 100);
}
})();

View file

@ -0,0 +1,662 @@
/**
* Unified Grav Form FilePond Handler
*
* This script initializes and configures FilePond instances for file uploads
* within Grav forms. It works with both normal and XHR form submissions.
* It also handles reinitializing FilePond instances after XHR form submissions.
*/
// Immediately-Invoked Function Expression for scoping
(function () {
// Check if script already loaded
if (window.gravFilepondHandlerLoaded) {
console.log('FilePond unified handler already loaded, skipping.');
return;
}
window.gravFilepondHandlerLoaded = true;
// Debugging - set to false for production
const debug = true;
// Helper function for logging
function log(message, type = 'log') {
if (!debug && type !== 'error') return;
const prefix = '[FilePond Handler]';
if (type === 'error') {
console.error(prefix, message);
} else if (type === 'warn') {
console.warn(prefix, message);
} else {
console.log(prefix, message);
}
}
// Track FilePond instances with their configuration
const pondInstances = new Map();
// Get translations from global object if available
const translations = window.GravForm?.translations?.PLUGIN_FORM || {
FILEPOND_REMOVE_FILE: 'Remove file',
FILEPOND_REMOVE_FILE_CONFIRMATION: 'Are you sure you want to remove this file?',
FILEPOND_CANCEL_UPLOAD: 'Cancel upload',
FILEPOND_ERROR_FILESIZE: 'File is too large',
FILEPOND_ERROR_FILETYPE: 'Invalid file type'
};
// Track initialization state
let initialized = false;
/**
* Get standard FilePond configuration for an element
* This is used for both initial setup and reinit after XHR
* @param {HTMLElement} element - The file input element
* @param {HTMLElement} container - The container element
* @returns {Object} Configuration object for FilePond
*/
function getFilepondConfig(element, container) {
if (!container) {
log('Container not provided for config extraction', 'error');
return null;
}
// Check if the field is required - this is correct location
const isRequired = element.hasAttribute('required') ||
container.hasAttribute('required') ||
container.getAttribute('data-required') === 'true';
// Then, add this code to remove the required attribute from the actual input
// to prevent browser validation errors, but keep track of the requirement
if (isRequired) {
// Store the required state on the container for our custom validation
container.setAttribute('data-required', 'true');
// Remove the required attribute from the input to avoid browser validation errors
element.removeAttribute('required');
}
try {
// Get settings from data attributes
const settingsAttr = container.getAttribute('data-grav-file-settings');
if (!settingsAttr) {
log('No file settings found for FilePond element', 'warn');
return null;
}
// Parse settings
let settings;
try {
settings = JSON.parse(settingsAttr);
log('Parsed settings:', settings);
} catch (e) {
log(`Error parsing file settings: ${e.message}`, 'error');
return null;
}
// Parse FilePond options
const filepondOptionsAttr = container.getAttribute('data-filepond-options') || '{}';
let filepondOptions;
try {
filepondOptions = JSON.parse(filepondOptionsAttr);
log('Parsed FilePond options:', filepondOptions);
} catch (e) {
log(`Error parsing FilePond options: ${e.message}`, 'error');
filepondOptions = {};
}
// Get URLs for upload and remove
const uploadUrl = container.getAttribute('data-file-url-add');
const removeUrl = container.getAttribute('data-file-url-remove');
if (!uploadUrl) {
log('Upload URL not found for FilePond element', 'warn');
return null;
}
// Parse previously uploaded files
const existingFiles = [];
const fileDataElements = container.querySelectorAll('[data-file]');
log(`Found ${fileDataElements.length} existing file data elements`);
fileDataElements.forEach(fileData => {
try {
const fileAttr = fileData.getAttribute('data-file');
log('File data attribute:', fileAttr);
const fileJson = JSON.parse(fileAttr);
if (fileJson && fileJson.name) {
existingFiles.push({
source: fileJson.name,
options: {
type: 'local',
file: {
name: fileJson.name,
size: fileJson.size,
type: fileJson.type
},
metadata: {
poster: fileJson.thumb_url || fileJson.path
}
}
});
}
} catch (e) {
log(`Error parsing file data: ${e.message}`, 'error');
}
});
log('Existing files:', existingFiles);
// Get form elements for Grav integration
const fieldName = container.getAttribute('data-file-field-name');
const form = element.closest('form');
const formNameInput = form ? form.querySelector('[name="__form-name__"]') : document.querySelector('[name="__form-name__"]');
const formIdInput = form ? form.querySelector('[name="__unique_form_id__"]') : document.querySelector('[name="__unique_form_id__"]');
const formNonceInput = form ? form.querySelector('[name="form-nonce"]') : document.querySelector('[name="form-nonce"]');
if (!formNameInput || !formIdInput || !formNonceInput) {
log('Missing required form inputs for proper Grav integration', 'warn');
}
// Configure FilePond
const options = {
// Core settings
name: settings.paramName,
maxFiles: settings.limit || null,
maxFileSize: `${settings.filesize}MB`,
acceptedFileTypes: settings.accept,
files: existingFiles,
// Server configuration - modified for Grav
server: {
process: {
url: uploadUrl,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
ondata: (formData) => {
// Safety check - ensure formData is valid
if (!formData) {
console.error('FormData is undefined in ondata');
return new FormData(); // Return empty FormData as fallback
}
// Add all required Grav form fields
if (formNameInput) formData.append('__form-name__', formNameInput.value);
if (formIdInput) formData.append('__unique_form_id__', formIdInput.value);
formData.append('__form-file-uploader__', '1');
if (formNonceInput) formData.append('form-nonce', formNonceInput.value);
formData.append('task', 'filesupload');
// Use fieldName from the outer scope
if (fieldName) {
formData.append('name', fieldName);
} else {
console.error('Field name is undefined, falling back to default');
formData.append('name', 'files');
}
// Add URI if needed
const uriInput = document.querySelector('[name="uri"]');
if (uriInput) {
formData.append('uri', uriInput.value);
}
// Note: Don't try to append file here, FilePond will do that based on the name parameter
// Just return the modified formData
log('Prepared form data for Grav upload');
return formData;
}
},
revert: removeUrl ? {
url: removeUrl,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
ondata: (formData, file) => {
// Add all required Grav form fields
if (formNameInput) formData.append('__form-name__', formNameInput.value);
if (formIdInput) formData.append('__unique_form_id__', formIdInput.value);
formData.append('__form-file-remover__', '1');
if (formNonceInput) formData.append('form-nonce', formNonceInput.value);
formData.append('name', fieldName);
// Add filename
formData.append('filename', file.filename);
log('Prepared form data for file removal');
return formData;
}
} : null
},
// Image Transform settings - both FilePond native settings and our custom ones
// Native settings
allowImagePreview: true,
allowImageResize: true,
allowImageTransform: true,
imagePreviewHeight: filepondOptions.imagePreviewHeight || 256,
// Transform settings
imageTransformOutputMimeType: filepondOptions.imageTransformOutputMimeType || 'image/jpeg',
imageTransformOutputQuality: filepondOptions.imageTransformOutputQuality || settings.resizeQuality || 90,
imageTransformOutputStripImageHead: filepondOptions.imageTransformOutputStripImageHead !== false,
// Resize settings
imageResizeTargetWidth: filepondOptions.imageResizeTargetWidth || settings.resizeWidth || null,
imageResizeTargetHeight: filepondOptions.imageResizeTargetHeight || settings.resizeHeight || null,
imageResizeMode: filepondOptions.imageResizeMode || 'cover',
imageResizeUpscale: filepondOptions.imageResizeUpscale || false,
// Crop settings
allowImageCrop: filepondOptions.allowImageCrop || false,
imageCropAspectRatio: filepondOptions.imageCropAspectRatio || null,
// Labels and translations
labelIdle: filepondOptions.labelIdle || '<span class="filepond--label-action">Browse</span> or drop files',
labelFileTypeNotAllowed: translations.FILEPOND_ERROR_FILETYPE || 'Invalid file type',
labelFileSizeNotAllowed: translations.FILEPOND_ERROR_FILESIZE || 'File is too large',
labelFileLoading: 'Loading',
labelFileProcessing: 'Uploading',
labelFileProcessingComplete: 'Upload complete',
labelFileProcessingAborted: 'Upload cancelled',
labelTapToCancel: translations.FILEPOND_CANCEL_UPLOAD || 'Cancel upload',
labelTapToRetry: 'Retry',
labelTapToUndo: 'Undo',
labelButtonRemoveItem: translations.FILEPOND_REMOVE_FILE || 'Remove',
// Style settings
stylePanelLayout: filepondOptions.stylePanelLayout || 'compact',
styleLoadIndicatorPosition: filepondOptions.styleLoadIndicatorPosition || 'center bottom',
styleProgressIndicatorPosition: filepondOptions.styleProgressIndicatorPosition || 'center bottom',
styleButtonRemoveItemPosition: filepondOptions.styleButtonRemoveItemPosition || 'right',
// Override with any remaining user-provided options
...filepondOptions
};
log('Prepared FilePond configuration:', options);
return options;
} catch (e) {
log(`Error creating FilePond configuration: ${e.message}`, 'error');
console.error(e); // Full error in console
return null;
}
}
/**
* Initialize a single FilePond instance
* @param {HTMLElement} element - The file input element to initialize
* @returns {FilePond|null} The created FilePond instance, or null if creation failed
*/
function initializeSingleFilePond(element) {
const container = element.closest('.filepond-root');
if (!container) {
log('FilePond container not found for input element', 'error');
return null;
}
// Don't initialize twice
if (container.classList.contains('filepond--hopper') || container.querySelector('.filepond--hopper')) {
log('FilePond already initialized for this element, skipping');
return null;
}
// Get the element ID or create a unique one for tracking
const elementId = element.id || `filepond-${Math.random().toString(36).substring(2, 15)}`;
// Get configuration
const config = getFilepondConfig(element, container);
if (!config) {
log('Failed to get configuration, cannot initialize FilePond', 'error');
return null;
}
log(`Initializing FilePond element ${elementId} with config`, config);
try {
// Create FilePond instance
const pond = FilePond.create(element, config);
log(`FilePond instance created successfully for element ${elementId}`);
// Store the instance and its configuration for potential reinit
pondInstances.set(elementId, {
instance: pond,
config: config,
container: container
});
// Add a reference to the element for easier lookup
element.filepondId = elementId;
container.filepondId = elementId;
// Handle form submission to ensure files are processed before submit
const form = element.closest('form');
if (form && !form._filepond_handler_attached) {
form._filepond_handler_attached = true;
form.addEventListener('submit', function (e) {
// Check for all FilePond instances in this form
const formPonds = Array.from(pondInstances.values())
.filter(info => info.instance && info.container.closest('form') === form);
const processingFiles = formPonds.reduce((total, info) => {
return total + info.instance.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING
).length;
}, 0);
if (processingFiles > 0) {
e.preventDefault();
alert('Please wait for all files to finish uploading before submitting the form.');
return false;
}
});
}
return pond;
} catch (e) {
log(`Error creating FilePond instance: ${e.message}`, 'error');
console.error(e); // Full error in console
return null;
}
}
/**
* Main FilePond initialization function
* This will find and initialize all uninitialized FilePond elements
*/
function initializeFilePond() {
log('Starting FilePond initialization');
// Make sure we have the libraries loaded
if (typeof window.FilePond === 'undefined') {
log('FilePond library not found. Will retry in 500ms...', 'warn');
setTimeout(initializeFilePond, 500);
return;
}
log('FilePond library found, continuing initialization');
// Register plugins if available
try {
if (window.FilePondPluginFileValidateSize) {
FilePond.registerPlugin(FilePondPluginFileValidateSize);
log('Registered FileValidateSize plugin');
}
if (window.FilePondPluginFileValidateType) {
FilePond.registerPlugin(FilePondPluginFileValidateType);
log('Registered FileValidateType plugin');
}
if (window.FilePondPluginImagePreview) {
FilePond.registerPlugin(FilePondPluginImagePreview);
log('Registered ImagePreview plugin');
}
if (window.FilePondPluginImageResize) {
FilePond.registerPlugin(FilePondPluginImageResize);
log('Registered ImageResize plugin');
}
if (window.FilePondPluginImageTransform) {
FilePond.registerPlugin(FilePondPluginImageTransform);
log('Registered ImageTransform plugin');
}
} catch (e) {
log(`Error registering plugins: ${e.message}`, 'error');
}
// Find all FilePond elements
const elements = document.querySelectorAll('.filepond-root input[type="file"]:not(.filepond--browser)');
if (elements.length === 0) {
log('No FilePond form elements found on the page');
return;
}
log(`Found ${elements.length} FilePond element(s)`);
// Process each FilePond element
elements.forEach((element, index) => {
log(`Initializing FilePond element #${index + 1}`);
initializeSingleFilePond(element);
});
initialized = true;
log('FilePond initialization complete');
}
/**
* Reinitialize a specific FilePond instance
* @param {HTMLElement} container - The FilePond container element
* @returns {FilePond|null} The reinitialized FilePond instance, or null if reinitialization failed
*/
function reinitializeSingleFilePond(container) {
if (!container) {
log('No container provided for reinitialization', 'error');
return null;
}
// Check if this is a FilePond container
if (!container.classList.contains('filepond-root')) {
log('Container is not a FilePond container', 'warn');
return null;
}
log(`Reinitializing FilePond container: ${container.id || 'unnamed'}`);
// If already initialized, destroy first
if (container.classList.contains('filepond--hopper') || container.querySelector('.filepond--hopper')) {
log('Container already has an active FilePond instance, destroying it first');
// Try to find and destroy through our internal tracking
const elementId = container.filepondId;
if (elementId && pondInstances.has(elementId)) {
const info = pondInstances.get(elementId);
if (info.instance) {
log(`Destroying tracked FilePond instance for element ${elementId}`);
info.instance.destroy();
pondInstances.delete(elementId);
}
} else {
// Fallback: Try to find via child element with class
const pondElement = container.querySelector('.filepond--root');
if (pondElement && pondElement._pond) {
log('Destroying FilePond instance via DOM reference');
pondElement._pond.destroy();
}
}
}
// Look for the file input
const input = container.querySelector('input[type="file"]:not(.filepond--browser)');
if (!input) {
log('No file input found in container for reinitialization', 'error');
return null;
}
// Create a new instance
return initializeSingleFilePond(input);
}
/**
* Reinitialize all FilePond instances
* This is used after XHR form submissions
*/
function reinitializeFilePond() {
log('Reinitializing all FilePond instances');
// Find all FilePond containers
const containers = document.querySelectorAll('.filepond-root');
if (containers.length === 0) {
log('No FilePond containers found for reinitialization');
return;
}
log(`Found ${containers.length} FilePond container(s) for reinitialization`);
// Process each container
containers.forEach((container, index) => {
log(`Reinitializing FilePond container #${index + 1}`);
reinitializeSingleFilePond(container);
});
log('FilePond reinitialization complete');
}
/**
* Helper function to support XHR form interaction
* This hooks into the GravFormXHR system if available
*/
function setupXHRIntegration() {
// Only run if GravFormXHR is available
if (window.GravFormXHR) {
log('Setting up XHR integration for FilePond');
// Store original submit function
const originalSubmit = window.GravFormXHR.submit;
// Override to handle FilePond files
window.GravFormXHR.submit = function (form) {
if (!form) {
return originalSubmit.apply(this, arguments);
}
// Check for any FilePond instances in the form
let hasPendingUploads = false;
// First check via our tracking
Array.from(pondInstances.values()).forEach(info => {
if (info.container.closest('form') === form) {
const processingFiles = info.instance.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING);
if (processingFiles.length > 0) {
hasPendingUploads = true;
}
}
});
// Fallback check for any untracked instances
if (!hasPendingUploads) {
const filepondContainers = form.querySelectorAll('.filepond-root');
filepondContainers.forEach(container => {
const pondElement = container.querySelector('.filepond--root');
if (pondElement && pondElement._pond) {
const pond = pondElement._pond;
const processingFiles = pond.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING);
if (processingFiles.length > 0) {
hasPendingUploads = true;
}
}
});
}
if (hasPendingUploads) {
alert('Please wait for all files to finish uploading before submitting the form.');
return false;
}
// Call the original submit function
return originalSubmit.apply(this, arguments);
};
// Set up listeners for form updates
document.addEventListener('grav-form-updated', function (e) {
log('Detected form update event, reinitializing FilePond instances');
setTimeout(reinitializeFilePond, 100);
});
}
}
/**
* Setup mutation observer to detect dynamically added FilePond elements
*/
function setupMutationObserver() {
if (window.MutationObserver) {
const observer = new MutationObserver((mutations) => {
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('filepond-root') ||
node.querySelector && node.querySelector('.filepond-root')) {
shouldCheck = true;
break;
}
}
}
}
if (shouldCheck) break;
}
if (shouldCheck) {
log('DOM changes detected that might include FilePond elements');
// Delay to ensure DOM is fully updated
setTimeout(initializeFilePond, 50);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
log('MutationObserver set up for FilePond elements');
}
}
/**
* Initialize when DOM is ready
*/
function domReadyInit() {
log('DOM ready, initializing FilePond');
initializeFilePond();
setupXHRIntegration();
setupMutationObserver();
}
// Handle different document ready states
if (document.readyState === 'loading') {
log('Document still loading, adding DOMContentLoaded listener');
document.addEventListener('DOMContentLoaded', domReadyInit);
} else {
log('Document already loaded, initializing now');
setTimeout(domReadyInit, 0);
}
// Also support initialization via window load event as a fallback
window.addEventListener('load', function () {
log('Window load event fired');
if (!initialized) {
log('FilePond not yet initialized, initializing now');
initializeFilePond();
}
});
// Expose functions to global scope for external usage
window.GravFilePond = {
initialize: initializeFilePond,
reinitialize: reinitializeFilePond,
reinitializeContainer: reinitializeSingleFilePond,
getInstances: () => Array.from(pondInstances.values()).map(info => info.instance)
};
// Log initialization start
log('FilePond unified handler script loaded and ready');
})();

View file

@ -0,0 +1,141 @@
/**
* FilePond Direct Fix - Emergency fix for XHR forms
*/
(function() {
// Directly attempt to initialize uninitialized FilePond elements
// without relying on any existing logic
console.log('FilePond Direct Fix loaded');
// Function to directly create FilePond instances
function initializeFilePondElements() {
console.log('Direct FilePond initialization attempt');
// Find uninitialized FilePond elements
const elements = document.querySelectorAll('.filepond-root:not(.filepond--hopper)');
if (elements.length === 0) {
return;
}
console.log(`Found ${elements.length} uninitialized FilePond elements`);
// Process each element
elements.forEach((element, index) => {
const input = element.querySelector('input[type="file"]:not(.filepond--browser)');
if (!input) {
console.log(`Element #${index + 1}: No suitable file input found`);
return;
}
console.log(`Element #${index + 1}: Found file input:`, input);
// Get settings
let settings = {};
try {
const settingsAttr = element.getAttribute('data-grav-file-settings');
if (settingsAttr) {
settings = JSON.parse(settingsAttr);
console.log('Parsed settings:', settings);
}
} catch (e) {
console.error('Failed to parse settings:', e);
}
// Get URLS
const uploadUrl = element.getAttribute('data-file-url-add');
const removeUrl = element.getAttribute('data-file-url-remove');
console.log('Upload URL:', uploadUrl);
console.log('Remove URL:', removeUrl);
try {
// Create FilePond instance directly
const pond = FilePond.create(input);
// Apply minimal configuration to make uploads work
if (pond) {
console.log(`Successfully created FilePond on element #${index + 1}`);
// Basic configuration to make it functional
pond.setOptions({
name: settings.paramName || input.name || 'files',
server: {
process: uploadUrl,
revert: removeUrl
},
// Transform options
imageTransformOutputMimeType: 'image/jpeg',
imageTransformOutputQuality: settings.resizeQuality || 90,
imageTransformOutputStripImageHead: true,
// Resize options
imageResizeTargetWidth: settings.resizeWidth || null,
imageResizeTargetHeight: settings.resizeHeight || null,
imageResizeMode: 'cover',
imageResizeUpscale: false
});
}
} catch (e) {
console.error(`Failed to create FilePond on element #${index + 1}:`, e);
}
});
}
// Monitor form submissions and DOM changes
function setupMonitoring() {
// Create MutationObserver to watch for DOM changes
if (window.MutationObserver) {
const observer = new MutationObserver((mutations) => {
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('filepond-root') ||
node.querySelector && node.querySelector('.filepond-root')) {
shouldCheck = true;
break;
}
}
}
}
if (shouldCheck) break;
}
if (shouldCheck) {
console.log('DOM changes detected that might include FilePond elements');
// Delay to ensure DOM is fully updated
setTimeout(initializeFilePondElements, 50);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
console.log('MutationObserver set up for FilePond elements');
}
}
// Set up the emergency fix
function init() {
// Set up monitoring
setupMonitoring();
// Expose global function for manual reinit
window.directFilePondInit = initializeFilePondElements;
// Initial check
setTimeout(initializeFilePondElements, 500);
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 0);
}
})();

View file

@ -0,0 +1,9 @@
/*!
* FilePondPluginFileValidateSize 2.2.8
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(e=e||self).FilePondPluginFileValidateSize=i()}(this,function(){"use strict";var e=function(e){var i=e.addFilter,E=e.utils,l=E.Type,_=E.replaceInString,n=E.toNaturalFileSize;return i("ALLOW_HOPPER_ITEM",function(e,i){var E=i.query;if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return!0;var l=E("GET_MAX_FILE_SIZE");if(null!==l&&e.size>l)return!1;var _=E("GET_MIN_FILE_SIZE");return!(null!==_&&e.size<_)}),i("LOAD_FILE",function(e,i){var E=i.query;return new Promise(function(i,l){if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return i(e);var I=E("GET_FILE_VALIDATE_SIZE_FILTER");if(I&&!I(e))return i(e);var t=E("GET_MAX_FILE_SIZE");if(null!==t&&e.size>t)l({status:{main:E("GET_LABEL_MAX_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_FILE_SIZE"),{filesize:n(t,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var L=E("GET_MIN_FILE_SIZE");if(null!==L&&e.size<L)l({status:{main:E("GET_LABEL_MIN_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MIN_FILE_SIZE"),{filesize:n(L,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var a=E("GET_MAX_TOTAL_FILE_SIZE");if(null!==a)if(E("GET_ACTIVE_ITEMS").reduce(function(e,i){return e+i.fileSize},0)>a)return void l({status:{main:E("GET_LABEL_MAX_TOTAL_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_TOTAL_FILE_SIZE"),{filesize:n(a,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});i(e)}}})}),{options:{allowFileSizeValidation:[!0,l.BOOLEAN],maxFileSize:[null,l.INT],minFileSize:[null,l.INT],maxTotalFileSize:[null,l.INT],fileValidateSizeFilter:[null,l.FUNCTION],labelMinFileSizeExceeded:["File is too small",l.STRING],labelMinFileSize:["Minimum file size is {filesize}",l.STRING],labelMaxFileSizeExceeded:["File is too large",l.STRING],labelMaxFileSize:["Maximum file size is {filesize}",l.STRING],labelMaxTotalFileSizeExceeded:["Maximum total size exceeded",l.STRING],labelMaxTotalFileSize:["Maximum total file size is {filesize}",l.STRING]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

View file

@ -0,0 +1,9 @@
/*!
* FilePondPluginFileValidateType 1.2.9
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).FilePondPluginFileValidateType=t()}(this,function(){"use strict";var e=function(e){var t=e.addFilter,n=e.utils,i=n.Type,T=n.isString,E=n.replaceInString,l=n.guesstimateMimeType,o=n.getExtensionFromFilename,r=n.getFilenameFromURL,u=function(e,t){return e.some(function(e){return/\*$/.test(e)?(n=e,(/^[^/]+/.exec(t)||[]).pop()===n.slice(0,-2)):e===t;var n})},a=function(e,t,n){if(0===t.length)return!0;var i=function(e){var t="";if(T(e)){var n=r(e),i=o(n);i&&(t=l(i))}else t=e.type;return t}(e);return n?new Promise(function(T,E){n(e,i).then(function(e){u(t,e)?T():E()}).catch(E)}):u(t,i)};return t("SET_ATTRIBUTE_TO_OPTION_MAP",function(e){return Object.assign(e,{accept:"acceptedFileTypes"})}),t("ALLOW_HOPPER_ITEM",function(e,t){var n=t.query;return!n("GET_ALLOW_FILE_TYPE_VALIDATION")||a(e,n("GET_ACCEPTED_FILE_TYPES"))}),t("LOAD_FILE",function(e,t){var n=t.query;return new Promise(function(t,i){if(n("GET_ALLOW_FILE_TYPE_VALIDATION")){var T=n("GET_ACCEPTED_FILE_TYPES"),l=n("GET_FILE_VALIDATE_TYPE_DETECT_TYPE"),o=a(e,T,l),r=function(){var e,t=T.map((e=n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES_MAP"),function(t){return null!==e[t]&&(e[t]||t)})).filter(function(e){return!1!==e}),l=t.filter(function(e,n){return t.indexOf(e)===n});i({status:{main:n("GET_LABEL_FILE_TYPE_NOT_ALLOWED"),sub:E(n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES"),{allTypes:l.join(", "),allButLastType:l.slice(0,-1).join(", "),lastType:l[l.length-1]})}})};if("boolean"==typeof o)return o?t(e):r();o.then(function(){t(e)}).catch(r)}else t(e)})}),{options:{allowFileTypeValidation:[!0,i.BOOLEAN],acceptedFileTypes:[[],i.ARRAY],labelFileTypeNotAllowed:["File is of invalid type",i.STRING],fileValidateTypeLabelExpectedTypes:["Expects {allButLastType} or {lastType}",i.STRING],fileValidateTypeLabelExpectedTypesMap:[{},i.OBJECT],fileValidateTypeDetectType:[null,i.FUNCTION]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

View file

@ -0,0 +1,8 @@
/*!
* FilePondPluginImagePreview 4.6.12
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
.filepond--image-preview-markup{position:absolute;left:0;top:0}.filepond--image-preview-wrapper{z-index:2}.filepond--image-preview-overlay{display:block;position:absolute;left:0;top:0;width:100%;min-height:5rem;max-height:7rem;margin:0;opacity:0;z-index:2;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.filepond--image-preview-overlay svg{width:100%;height:auto;color:inherit;max-height:inherit}.filepond--image-preview-overlay-idle{mix-blend-mode:multiply;color:rgba(40,40,40,.85)}.filepond--image-preview-overlay-success{mix-blend-mode:normal;color:#369763}.filepond--image-preview-overlay-failure{mix-blend-mode:normal;color:#c44e47}@supports (-webkit-marquee-repetition:infinite) and ((-o-object-fit:fill) or (object-fit:fill)){.filepond--image-preview-overlay-idle{mix-blend-mode:normal}}.filepond--image-preview-wrapper{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:absolute;left:0;top:0;right:0;height:100%;margin:0;border-radius:.45em;overflow:hidden;background:rgba(0,0,0,.01)}.filepond--image-preview{position:absolute;left:0;top:0;z-index:1;display:flex;align-items:center;height:100%;width:100%;pointer-events:none;background:#222;will-change:transform,opacity}.filepond--image-clip{position:relative;overflow:hidden;margin:0 auto}.filepond--image-clip[data-transparency-indicator=grid] canvas,.filepond--image-clip[data-transparency-indicator=grid] img{background-color:#fff;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg' fill='%23eee'%3E%3Cpath d='M0 0h50v50H0M50 50h50v50H50'/%3E%3C/svg%3E");background-size:1.25em 1.25em}.filepond--image-bitmap,.filepond--image-vector{position:absolute;left:0;top:0;will-change:transform}.filepond--root[data-style-panel-layout~=integrated] .filepond--image-preview-wrapper{border-radius:0}.filepond--root[data-style-panel-layout~=integrated] .filepond--image-preview{height:100%;display:flex;justify-content:center;align-items:center}.filepond--root[data-style-panel-layout~=circle] .filepond--image-preview-wrapper{border-radius:99999rem}.filepond--root[data-style-panel-layout~=circle] .filepond--image-preview-overlay{top:auto;bottom:0;-webkit-transform:scaleY(-1);transform:scaleY(-1)}.filepond--root[data-style-panel-layout~=circle] .filepond--file .filepond--file-action-button[data-align*=bottom]:not([data-align*=center]){margin-bottom:.325em}.filepond--root[data-style-panel-layout~=circle] .filepond--file [data-align*=left]{left:calc(50% - 3em)}.filepond--root[data-style-panel-layout~=circle] .filepond--file [data-align*=right]{right:calc(50% - 3em)}.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=left],.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=right]{margin-bottom:.5125em}.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=center]{margin-top:0;margin-bottom:.1875em;margin-left:.1875em}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
/*!
* FilePondPluginImageResize 2.0.10
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).FilePondPluginImageResize=t()}(this,function(){"use strict";var e=function(e){var t=e.addFilter,i=e.utils.Type;return t("DID_LOAD_ITEM",function(e,t){var i=t.query;return new Promise(function(t,n){var r=e.file;if(!function(e){return/^image/.test(e.type)}(r)||!i("GET_ALLOW_IMAGE_RESIZE"))return t(e);var u=i("GET_IMAGE_RESIZE_MODE"),o=i("GET_IMAGE_RESIZE_TARGET_WIDTH"),a=i("GET_IMAGE_RESIZE_TARGET_HEIGHT"),l=i("GET_IMAGE_RESIZE_UPSCALE");if(null===o&&null===a)return t(e);var d,f,E,s=null===o?a:o,c=null===a?s:a,I=URL.createObjectURL(r);d=I,f=function(i){if(URL.revokeObjectURL(I),!i)return t(e);var n=i.width,r=i.height,o=(e.getMetadata("exif")||{}).orientation||-1;if(o>=5&&o<=8){var a=[r,n];n=a[0],r=a[1]}if(n===s&&r===c)return t(e);if(!l)if("cover"===u){if(n<=s||r<=c)return t(e)}else if(n<=s&&r<=s)return t(e);e.setMetadata("resize",{mode:u,upscale:l,size:{width:s,height:c}}),t(e)},(E=new Image).onload=function(){var e=E.naturalWidth,t=E.naturalHeight;E=null,f({width:e,height:t})},E.onerror=function(){return f(null)},E.src=d})}),{options:{allowImageResize:[!0,i.BOOLEAN],imageResizeMode:["cover",i.STRING],imageResizeUpscale:[!0,i.BOOLEAN],imageResizeTargetWidth:[null,i.INT],imageResizeTargetHeight:[null,i.INT]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
.form-group.has-errors{background:rgba(255,0,0,.05);border:1px solid rgba(255,0,0,.2);border-radius:3px;margin:0 -5px;padding:0 5px}.form-errors{color:#b52b27}.form-honeybear{display:none;position:absolute !important;height:1px;width:1px;overflow:hidden;clip-path:rect(0px, 1px, 1px, 0px)}.form-errors p{margin:0}.form-input-file input{display:none}.form-input-file .dz-default.dz-message{position:absolute;text-align:center;left:0;right:0;top:50%;transform:translateY(-50%);margin:0}.form-input-file.dropzone{position:relative;min-height:70px;border-radius:3px;margin-bottom:.85rem;border:2px dashed #ccc;color:#aaa;padding:.5rem}.form-input-file.dropzone .dz-preview{margin:.5rem}.form-input-file.dropzone .dz-preview:hover{z-index:2}.form-input-file.dropzone .dz-preview .dz-error-message{min-width:140px;width:auto}.form-input-file.dropzone .dz-preview .dz-image,.form-input-file.dropzone .dz-preview.dz-file-preview .dz-image{border-radius:3px;z-index:1}.form-tabs .tabs-nav{display:flex;padding-top:1px;margin-bottom:-1px}.form-tabs .tabs-nav a{flex:1;transition:color .5s ease,background .5s ease;cursor:pointer;text-align:center;padding:10px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #ccc;border-radius:5px 5px 0 0}.form-tabs .tabs-nav a.active{border:1px solid #ccc;border-bottom:1px solid rgba(0,0,0,0);margin:0 -1px}.form-tabs .tabs-nav a.active span{color:#000}.form-tabs .tabs-nav span{display:inline-block;line-height:1.1}.form-tabs.subtle .tabs-nav{margin-right:0 !important}.form-tabs .tabs-content .tab__content{display:none;padding-top:2rem}.form-tabs .tabs-content .tab__content.active{display:block}.checkboxes{display:inline-block}.checkboxes label{display:inline;cursor:pointer;position:relative;padding:0 0 0 20px;margin-right:15px}.checkboxes label:before{content:"";display:inline-block;width:20px;height:20px;left:0;margin-top:0;margin-right:10px;position:absolute;border-radius:3px;border:1px solid #e6e6e6}.checkboxes input[type=checkbox]{display:none}.checkboxes input[type=checkbox]:checked+label:before{content:"✓";font-size:20px;line-height:1;text-align:center}.checkboxes.toggleable label{margin-right:0}.form-field-toggleable .checkboxes.toggleable{margin-right:5px;vertical-align:middle}.form-field-toggleable .checkboxes+label{display:inline-block}.switch-toggle{display:inline-flex;overflow:hidden;border-radius:3px;line-height:35px;border:1px solid #ccc}.switch-toggle input[type=radio]{position:absolute;visibility:hidden;display:none}.switch-toggle label{display:inline-block;cursor:pointer;padding:0 15px;margin:0;white-space:nowrap;color:inherit;transition:background-color .5s ease}.switch-toggle input.highlight:checked+label{background:#333;color:#fff}.switch-toggle input:checked+label{color:#fff;background:#999}.signature-pad{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;font-size:10px;width:100%;height:100%;max-width:700px;max-height:460px;border:1px solid #f0f0f0;background-color:#fff;padding:16px}.signature-pad--body{position:relative;-webkit-box-flex:1;-ms-flex:1;flex:1;border:1px solid #f6f6f6;min-height:100px}.signature-pad--body canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:4px;box-shadow:0 0 5px rgba(0,0,0,.02) inset}.signature-pad--footer{color:#c3c3c3;text-align:center;font-size:1.2em}.signature-pad--actions{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;margin-top:8px}[data-grav-field=array] .form-row{display:flex;align-items:center;margin-bottom:.5rem}[data-grav-field=array] .form-row>input,[data-grav-field=array] .form-row>textarea{margin:0 .5rem;display:inline-block}.form-data.basic-captcha .form-input-wrapper{border:1px solid #ccc;border-radius:5px;display:flex;overflow:hidden}.form-data.basic-captcha .form-input-prepend{display:flex;color:#333;background-color:#ccc;flex-shrink:0}.form-data.basic-captcha .form-input-prepend img{margin:0}.form-data.basic-captcha .form-input-prepend button>svg{margin:0 8px;width:18px;height:18px}.form-data.basic-captcha input.form-input{border:0}/*# sourceMappingURL=form-styles.css.map */
.form-group.has-errors{background:rgba(255,0,0,.05);border:1px solid rgba(255,0,0,.2);border-radius:3px;margin:0 -5px;padding:0 5px}.form-errors{color:#b52b27}.form-honeybear{display:none;position:absolute !important;height:1px;width:1px;overflow:hidden;clip-path:rect(0px, 1px, 1px, 0px)}.form-errors p{margin:0}.form-input-file input{display:none}.form-input-file .dz-default.dz-message{position:absolute;text-align:center;left:0;right:0;top:50%;transform:translateY(-50%);margin:0}.form-input-file.dropzone{position:relative;min-height:70px;border-radius:3px;margin-bottom:.85rem;border:2px dashed #ccc;color:#aaa;padding:.5rem}.form-input-file.dropzone .dz-preview{margin:.5rem}.form-input-file.dropzone .dz-preview:hover{z-index:2}.form-input-file.dropzone .dz-preview .dz-image img{margin:0}.form-input-file.dropzone .dz-preview .dz-remove{font-size:16px;position:absolute;top:3px;right:3px;display:inline-flex;height:20px;width:20px;background-color:red;justify-content:center;align-items:center;color:#fff;font-weight:bold;border-radius:50%;cursor:pointer;z-index:20}.form-input-file.dropzone .dz-preview .dz-remove:hover{background-color:darkred;text-decoration:none}.form-input-file.dropzone .dz-preview .dz-error-message{min-width:140px;width:auto}.form-input-file.dropzone .dz-preview .dz-image,.form-input-file.dropzone .dz-preview.dz-file-preview .dz-image{border-radius:3px;z-index:1}.filepond--root.form-input{min-height:7rem;height:auto;overflow:hidden;border:0}.form-tabs .tabs-nav{display:flex;padding-top:1px;margin-bottom:-1px}.form-tabs .tabs-nav a{flex:1;transition:color .5s ease,background .5s ease;cursor:pointer;text-align:center;padding:10px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #ccc;border-radius:5px 5px 0 0}.form-tabs .tabs-nav a.active{border:1px solid #ccc;border-bottom:1px solid rgba(0,0,0,0);margin:0 -1px}.form-tabs .tabs-nav a.active span{color:#000}.form-tabs .tabs-nav span{display:inline-block;line-height:1.1}.form-tabs.subtle .tabs-nav{margin-right:0 !important}.form-tabs .tabs-content .tab__content{display:none;padding-top:2rem}.form-tabs .tabs-content .tab__content.active{display:block}.checkboxes{display:inline-block}.checkboxes label{display:inline;cursor:pointer;position:relative;padding:0 0 0 20px;margin-right:15px}.checkboxes label:before{content:"";display:inline-block;width:20px;height:20px;left:0;margin-top:0;margin-right:10px;position:absolute;border-radius:3px;border:1px solid #e6e6e6}.checkboxes input[type=checkbox]{display:none}.checkboxes input[type=checkbox]:checked+label:before{content:"✓";font-size:20px;line-height:1;text-align:center}.checkboxes.toggleable label{margin-right:0}.form-field-toggleable .checkboxes.toggleable{margin-right:5px;vertical-align:middle}.form-field-toggleable .checkboxes+label{display:inline-block}.switch-toggle{display:inline-flex;overflow:hidden;border-radius:3px;line-height:35px;border:1px solid #ccc}.switch-toggle input[type=radio]{position:absolute;visibility:hidden;display:none}.switch-toggle label{display:inline-block;cursor:pointer;padding:0 15px;margin:0;white-space:nowrap;color:inherit;transition:background-color .5s ease}.switch-toggle input.highlight:checked+label{background:#333;color:#fff}.switch-toggle input:checked+label{color:#fff;background:#999}.signature-pad{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;font-size:10px;width:100%;height:100%;max-width:700px;max-height:460px;border:1px solid #f0f0f0;background-color:#fff;padding:16px}.signature-pad--body{position:relative;-webkit-box-flex:1;-ms-flex:1;flex:1;border:1px solid #f6f6f6;min-height:100px}.signature-pad--body canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:4px;box-shadow:0 0 5px rgba(0,0,0,.02) inset}.signature-pad--footer{color:#c3c3c3;text-align:center;font-size:1.2em}.signature-pad--actions{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;margin-top:8px}[data-grav-field=array] .form-row{display:flex;align-items:center;margin-bottom:.5rem}[data-grav-field=array] .form-row>input,[data-grav-field=array] .form-row>textarea{margin:0 .5rem;display:inline-block}.form-data.basic-captcha .form-input-wrapper{border:1px solid #ccc;border-radius:5px;display:flex;overflow:hidden}.form-data.basic-captcha .form-input-prepend{display:flex;color:#333;background-color:#ccc;flex-shrink:0}.form-data.basic-captcha .form-input-prepend img{margin:0}.form-data.basic-captcha .form-input-prepend button>svg{margin:0 8px;width:18px;height:18px}.form-data.basic-captcha input.form-input{border:0}/*# sourceMappingURL=form-styles.css.map */

View file

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["../scss/form-styles.scss"],"names":[],"mappings":"CAGA,uBACI,6BACA,kCACA,kBACA,cACA,cAGJ,aACI,cAGJ,gBACI,aACA,6BACA,WACA,UACA,gBACA,mCAGJ,eACI,SAKA,uBACI,aAGJ,wCACI,kBACA,kBACA,OACA,QACA,QACA,2BACA,SAGJ,0BACI,kBACA,gBACA,kBACA,qBACA,uBACA,WACA,cAEA,sCACI,aAEA,4CACI,UAGJ,wDACI,gBACA,WAGJ,gHAEI,kBACA,UAWZ,qBACI,aACA,gBAEA,mBAEA,uBACI,OACA,8CACA,eACA,kBACA,aACA,aACA,mBACA,uBACA,6BACA,0BAEA,8BACI,sBACA,sCACA,cAEA,mCACI,MAtGA,KA2GZ,0BACI,qBACA,gBAKR,4BACI,0BAKA,uCACI,aACA,iBAEA,8CACI,cAOhB,YACI,qBAEA,kBACI,eACA,eACA,kBACA,mBACA,kBAGJ,yBACI,WACA,qBACA,WACA,YACA,OACA,aACA,kBACA,kBACA,kBAEA,yBAGJ,iCACI,aAEJ,sDACI,YACA,eACA,cACA,kBAGJ,6BACI,eAMJ,8CACI,iBACA,sBAEJ,yCACI,qBAKR,eACI,oBACA,gBACA,kBACA,iBACA,sBAEA,iCACI,kBACA,kBACA,aAGJ,qBACI,qBACA,eACA,eACA,SACA,mBACA,cACA,qCAGJ,6CACI,gBACA,WAGJ,mCACI,WACA,gBAOR,eACI,kBACA,oBACA,oBACA,aACA,4BACA,6BACA,0BACA,sBACA,eACA,WACA,YACA,gBACA,iBACA,yBACA,sBACA,aAGJ,qBACI,kBACA,mBACA,WACA,OACA,yBACA,iBAGJ,4BACI,kBACA,OACA,MACA,WACA,YACA,kBACA,yCAGJ,uBACI,cACA,kBACA,gBAGJ,wBACI,oBACA,oBACA,aACA,yBACA,sBACA,8BACA,eAGJ,kCACI,aACA,mBACA,oBAGJ,mFAGI,eACA,qBAIA,6CACI,sBACA,kBACA,aACA,gBAEJ,6CACI,aACA,WACA,sBACA,cACA,iDACI,SAEJ,wDACI,aACA,WACA,YAGR,0CACI","file":"form-styles.css"}
{"version":3,"sourceRoot":"","sources":["../scss/form-styles.scss"],"names":[],"mappings":"CAGA,uBACI,6BACA,kCACA,kBACA,cACA,cAGJ,aACI,cAGJ,gBACI,aACA,6BACA,WACA,UACA,gBACA,mCAGJ,eACI,SAKA,uBACI,aAGJ,wCACI,kBACA,kBACA,OACA,QACA,QACA,2BACA,SAGJ,0BACI,kBACA,gBACA,kBACA,qBACA,uBACA,WACA,cAEA,sCACI,aAEA,4CACI,UAGJ,oDACE,SAGF,iDACE,eACA,kBACA,QACA,UACA,oBACA,YACA,WACA,qBACA,uBACA,mBACA,WACA,iBACA,kBACA,eACA,WACA,uDACI,yBACA,qBAIN,wDACI,gBACA,WAGJ,gHAEI,kBACA,UAOhB,2BACE,gBACA,YACA,gBACA,SAME,qBACI,aACA,gBAEA,mBAEA,uBACI,OACA,8CACA,eACA,kBACA,aACA,aACA,mBACA,uBACA,6BACA,0BAEA,8BACI,sBACA,sCACA,cAEA,mCACI,MAtIA,KA2IZ,0BACI,qBACA,gBAKR,4BACI,0BAKA,uCACI,aACA,iBAEA,8CACI,cAOhB,YACI,qBAEA,kBACI,eACA,eACA,kBACA,mBACA,kBAGJ,yBACI,WACA,qBACA,WACA,YACA,OACA,aACA,kBACA,kBACA,kBAEA,yBAGJ,iCACI,aAEJ,sDACI,YACA,eACA,cACA,kBAGJ,6BACI,eAMJ,8CACI,iBACA,sBAEJ,yCACI,qBAKR,eACI,oBACA,gBACA,kBACA,iBACA,sBAEA,iCACI,kBACA,kBACA,aAGJ,qBACI,qBACA,eACA,eACA,SACA,mBACA,cACA,qCAGJ,6CACI,gBACA,WAGJ,mCACI,WACA,gBAOR,eACI,kBACA,oBACA,oBACA,aACA,4BACA,6BACA,0BACA,sBACA,eACA,WACA,YACA,gBACA,iBACA,yBACA,sBACA,aAGJ,qBACI,kBACA,mBACA,WACA,OACA,yBACA,iBAGJ,4BACI,kBACA,OACA,MACA,WACA,YACA,kBACA,yCAGJ,uBACI,cACA,kBACA,gBAGJ,wBACI,oBACA,oBACA,aACA,yBACA,sBACA,8BACA,eAGJ,kCACI,aACA,mBACA,oBAGJ,mFAGI,eACA,qBAIA,6CACI,sBACA,kBACA,aACA,gBAEJ,6CACI,aACA,WACA,sBACA,cACA,iDACI,SAEJ,wDACI,aACA,WACA,YAGR,0CACI","file":"form-styles.css"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,24 +1,461 @@
function attachFormSubmitListener(formId) {
var form = document.getElementById(formId);
if (!form) {
console.warn('Form with ID "' + formId + '" not found.');
return;
}
form.addEventListener('submit', function(e) {
// Prevent standard form submission
e.preventDefault();
// Submit the form via Ajax
var xhr = new XMLHttpRequest();
xhr.open(form.getAttribute('method'), form.getAttribute('action'));
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = function() {
if (xhr.status === 200) {
form.innerHTML = xhr.responseText; // Update the current form's innerHTML
/**
* Grav Form XHR Submitter
*
* A modular system for handling form submissions via XMLHttpRequest (AJAX).
* Features include content replacement, captcha handling, and error management.
*/
(function() {
'use strict';
// Main namespace
window.GravFormXHR = {};
/**
* Core Module - Contains configuration and utility functions
*/
const Core = {
config: {
debug: false,
enableLoadingIndicator: false
},
/**
* Configure global settings
* @param {Object} options - Configuration options
*/
configure: function(options) {
Object.assign(this.config, options);
},
/**
* Logger utility
* @param {string} message - Message to log
* @param {string} level - Log level ('log', 'warn', 'error')
*/
log: function(message, level = 'log') {
if (!this.config.debug) return;
const validLevels = ['log', 'warn', 'error'];
const finalLevel = validLevels.includes(level) ? level : 'log';
console[finalLevel](`[GravFormXHR] ${message}`);
},
/**
* Display an error message within a target element
* @param {HTMLElement} target - The element to display the error in
* @param {string} message - The error message
*/
displayError: function(target, message) {
const errorMsgContainer = target.querySelector('.form-messages') || target;
const errorMsg = document.createElement('div');
errorMsg.className = 'form-message error';
errorMsg.textContent = message;
errorMsgContainer.insertBefore(errorMsg, errorMsgContainer.firstChild);
}
};
/**
* DOM Module - Handles DOM manipulation and form tracking
*/
const DOM = {
/**
* Find a form wrapper by formId
* @param {string} formId - ID of the form
* @returns {HTMLElement|null} - The wrapper element or null
*/
getFormWrapper: function(formId) {
const wrapperId = formId + '-wrapper';
return document.getElementById(wrapperId);
},
/**
* Add or remove loading indicators
* @param {HTMLElement} form - The form element
* @param {HTMLElement} wrapper - The wrapper element
* @param {boolean} isLoading - Whether to add or remove loading classes
*/
updateLoadingState: function(form, wrapper, isLoading) {
if (!Core.config.enableLoadingIndicator) return;
if (isLoading) {
wrapper.classList.add('loading');
form.classList.add('submitting');
} else {
// Handle HTTP error responses (optional)
console.error('Form submission failed with status: ' + xhr.status);
wrapper.classList.remove('loading');
form.classList.remove('submitting');
}
};
xhr.send(new URLSearchParams(new FormData(form)).toString());
});
}
},
/**
* Update form content with server response
* @param {string} responseText - Server response HTML
* @param {string} wrapperId - ID of the wrapper to update
* @param {string} formId - ID of the original form
*/
updateFormContent: function(responseText, wrapperId, formId) {
const wrapperElement = document.getElementById(wrapperId);
if (!wrapperElement) {
console.error(`Cannot update content: Wrapper #${wrapperId} not found`);
return;
}
Core.log(`Updating content for wrapper: ${wrapperId}`);
// Parse response
const tempDiv = document.createElement('div');
try {
tempDiv.innerHTML = responseText;
} catch (e) {
console.error(`Error parsing response HTML for wrapper: ${wrapperId}`, e);
Core.displayError(wrapperElement, 'An error occurred processing the server response.');
return;
}
try {
this._updateWrapperContent(tempDiv, wrapperElement, wrapperId, formId);
this._reinitializeUpdatedForm(wrapperElement, formId);
} catch (e) {
console.error(`Error during content update for wrapper ${wrapperId}:`, e);
Core.displayError(wrapperElement, 'An error occurred updating the form content.');
}
},
/**
* Update wrapper content based on response parsing strategy
* @private
*/
_updateWrapperContent: function(tempDiv, wrapperElement, wrapperId, formId) {
// Strategy 1: Look for matching wrapper ID in response
const newWrapperElement = tempDiv.querySelector('#' + wrapperId);
if (newWrapperElement) {
wrapperElement.innerHTML = newWrapperElement.innerHTML;
Core.log(`Update using newWrapperElement.innerHTML SUCCESSFUL for wrapper: ${wrapperId}`);
return;
}
// Strategy 2: Look for matching form ID in response
const hasMatchingForm = tempDiv.querySelector('#' + formId);
if (hasMatchingForm) {
Core.log(`Wrapper element #${wrapperId} not found in XHR response, but found matching form. Using entire response.`);
wrapperElement.innerHTML = tempDiv.innerHTML;
return;
}
// Strategy 3: Look for toast messages
const hasToastMessages = tempDiv.querySelector('.toast');
if (hasToastMessages) {
Core.log('Found toast messages in response. Updating wrapper with the response.');
wrapperElement.innerHTML = tempDiv.innerHTML;
return;
}
// Fallback: Use entire response with warning
Core.log('No matching content found in response. Response may not be valid for this wrapper.', 'warn');
wrapperElement.innerHTML = tempDiv.innerHTML;
},
/**
* Reinitialize updated form and its components
* @private
*/
_reinitializeUpdatedForm: function(wrapperElement, formId) {
const updatedForm = wrapperElement.querySelector('#' + formId);
if (updatedForm) {
Core.log(`Re-running initialization for form ${formId} after update`);
// First reinitialize any captchas
CaptchaManager.reinitializeAll(updatedForm);
// Trigger mutation._grav event for Dropzone and other field reinitializations
setTimeout(() => {
Core.log('Triggering mutation._grav event for field reinitialization');
// Trigger using jQuery if available (preferred method for compatibility)
if (typeof jQuery !== 'undefined') {
jQuery('body').trigger('mutation._grav', [wrapperElement]);
} else {
// Fallback: dispatch native custom event
const event = new CustomEvent('mutation._grav', {
detail: { target: wrapperElement },
bubbles: true
});
document.body.dispatchEvent(event);
}
}, 0);
// Then re-attach the XHR listener
setTimeout(() => {
FormHandler.setupListener(formId);
}, 10);
} else {
// Check if this was a successful submission with just a message
const hasSuccessMessage = wrapperElement.querySelector('.toast-success, .form-success');
if (hasSuccessMessage) {
Core.log('No form found after update, but success message detected. This appears to be a successful submission.');
} else {
console.warn(`Could not find form #${formId} inside the updated wrapper after update. Cannot re-attach listener/initializers.`);
}
}
}
};
/**
* XHR Module - Handles XMLHttpRequest operations
*/
const XHRManager = {
/**
* Send form data via XHR
* @param {HTMLFormElement} form - The form to submit
*/
sendFormData: function(form) {
const formId = form.id;
const wrapperId = formId + '-wrapper';
const wrapperElement = DOM.getFormWrapper(formId);
if (!wrapperElement) {
console.error(`XHR submission: Target wrapper element #${wrapperId} not found on the page! Cannot proceed.`);
form.innerHTML = '<p class="form-message error">Error: Form wrapper missing. Cannot update content.</p>';
return;
}
Core.log(`Initiating XHR submission for form: ${formId}, targeting wrapper: ${wrapperId}`);
DOM.updateLoadingState(form, wrapperElement, true);
const xhr = new XMLHttpRequest();
xhr.open(form.getAttribute('method') || 'POST', form.getAttribute('action') || window.location.href);
// Set Headers
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('X-Grav-Form-XHR', 'true');
// Success handler
xhr.onload = () => {
Core.log(`XHR request completed for form: ${formId}, Status: ${xhr.status}`);
DOM.updateLoadingState(form, wrapperElement, false);
if (xhr.status >= 200 && xhr.status < 300) {
DOM.updateFormContent(xhr.responseText, wrapperId, formId);
} else {
Core.log(`Form submission failed for form: ${formId}, HTTP Status: ${xhr.status} ${xhr.statusText}`, 'error');
Core.displayError(wrapperElement, `An error occurred during submission (Status: ${xhr.status}). Please check the form and try again.`);
}
};
// Network error handler
xhr.onerror = () => {
Core.log(`Form submission failed due to network error for form: ${formId}`, 'error');
DOM.updateLoadingState(form, wrapperElement, false);
Core.displayError(wrapperElement, 'A network error occurred. Please check your connection and try again.');
};
// Prepare and send data
try {
const formData = new FormData(form);
const urlEncodedData = new URLSearchParams(formData).toString();
Core.log(`Sending XHR request for form: ${formId} with custom header X-Grav-Form-XHR`);
xhr.send(urlEncodedData);
} catch (e) {
Core.log(`Error preparing or sending XHR request for form: ${formId}: ${e.message}`, 'error');
DOM.updateLoadingState(form, wrapperElement, false);
Core.displayError(wrapperElement, 'An unexpected error occurred before sending the form.');
}
}
};
/**
* CaptchaManager - Handles captcha registration and initialization
*/
const CaptchaManager = {
providers: {},
/**
* Register a captcha provider
* @param {string} name - Provider name
* @param {object} provider - Provider object with init and reset methods
*/
register: function(name, provider) {
this.providers[name] = provider;
Core.log(`Registered captcha provider: ${name}`);
},
/**
* Get a provider by name
* @param {string} name - Provider name
* @returns {object|null} Provider object or null if not found
*/
getProvider: function(name) {
return this.providers[name] || null;
},
/**
* Get all registered providers
* @returns {object} Object containing all providers
*/
getProviders: function() {
return this.providers;
},
/**
* Reinitialize all captchas in a form
* @param {HTMLFormElement} form - Form element containing captchas
*/
reinitializeAll: function(form) {
if (!form || !form.id) return;
const formId = form.id;
const containers = form.querySelectorAll('[data-captcha-provider]');
containers.forEach(container => {
const providerName = container.dataset.captchaProvider;
Core.log(`Found captcha container for provider: ${providerName} in form: ${formId}`);
const provider = this.getProvider(providerName);
if (provider && typeof provider.reset === 'function') {
setTimeout(() => {
try {
provider.reset(container, form);
Core.log(`Successfully reset ${providerName} captcha in form: ${formId}`);
} catch (e) {
console.error(`Error resetting ${providerName} captcha:`, e);
}
}, 0);
} else {
console.warn(`Could not reset captcha provider "${providerName}" - provider not registered or missing reset method`);
}
});
}
};
/**
* FormHandler - Handles form submission and event listeners
*/
const FormHandler = {
/**
* Submit a form via XHR
* @param {HTMLFormElement} form - Form to submit
*/
submitForm: function(form) {
if (!form || !form.id) {
console.error('submitForm called with invalid form element or form missing ID.');
return;
}
XHRManager.sendFormData(form);
},
/**
* Set up XHR submission listener for a form
* @param {string} formId - ID of the form
*/
setupListener: function(formId) {
setTimeout(() => {
const form = document.getElementById(formId);
if (!form) {
Core.log(`XHR Setup (delayed): Form with ID "${formId}" not found.`, 'warn');
return;
}
// Remove stale marker from previous runs
delete form.dataset.directXhrListenerAttached;
// Check if any captcha provider is handling the submission
const captchaContainer = form.querySelector('[data-captcha-provider][data-intercepts-submit="true"]');
if (!captchaContainer) {
// No intercepting captcha found, attach direct listener
this._attachDirectListener(form);
} else {
// Captcha will intercept, don't attach direct listener
const providerName = captchaContainer.dataset.captchaProvider;
Core.log(`XHR listener deferred: ${providerName} should intercept submit for form: ${formId}`);
// Ensure no stale listener marker remains
delete form.dataset.directXhrListenerAttached;
}
}, 0);
},
/**
* Attach a direct submit event listener to a form
* @private
* @param {HTMLFormElement} form - Form element
*/
_attachDirectListener: function(form) {
// Only proceed if XHR is enabled for this form
if (form.dataset.xhrEnabled !== 'true') {
Core.log(`XHR not enabled for form: ${form.id}. Skipping direct listener attachment.`);
return;
}
// Check if we already attached a listener
if (form.dataset.directXhrListenerAttached === 'true') {
Core.log(`Direct XHR listener already attached for form: ${form.id}`);
return;
}
const directXhrSubmitHandler = (event) => {
Core.log(`Direct XHR submit handler triggered for form: ${form.id}`);
event.preventDefault();
FormHandler.submitForm(form);
};
Core.log(`Attaching direct XHR listener for form: ${form.id}`);
form.addEventListener('submit', directXhrSubmitHandler);
form.dataset.directXhrListenerAttached = 'true';
}
};
// Initialize basic built-in captcha handlers
// Other providers will register themselves via separate handler JS files
const initializeBasicCaptchaHandlers = function() {
// Basic captcha handler (image refresh etc.)
CaptchaManager.register('basic-captcha', {
reset: function(container, form) {
const formId = form.id;
const captchaImg = container.querySelector('img');
const captchaInput = container.querySelector('input[type="text"]');
if (captchaImg) {
// Add a timestamp to force image reload
const timestamp = new Date().getTime();
const imgSrc = captchaImg.src.split('?')[0] + '?t=' + timestamp;
captchaImg.src = imgSrc;
// Clear any existing input
if (captchaInput) {
captchaInput.value = '';
}
Core.log(`Reset basic-captcha for form: ${formId}`);
}
}
});
};
// Initialize basic captcha handlers
initializeBasicCaptchaHandlers();
// --- Expose Public API ---
// Core configuration
window.GravFormXHR.configure = Core.configure.bind(Core);
// Form submission
window.GravFormXHR.submit = FormHandler.submitForm.bind(FormHandler);
window.GravFormXHR.setupListener = FormHandler.setupListener.bind(FormHandler);
// Captcha management
window.GravFormXHR.captcha = CaptchaManager;
// Legacy support
window.GravFormXHRSubmitters = {submit: FormHandler.submitForm.bind(FormHandler)};
window.attachFormSubmitListener = FormHandler.setupListener.bind(FormHandler);
})();

View file

@ -1,7 +1,7 @@
name: Form
slug: form
type: plugin
version: 7.4.2
version: 8.1.0
description: Enables forms handling and processing
icon: check-square
author:
@ -14,7 +14,7 @@ bugs: https://github.com/getgrav/grav-plugin-form/issues
license: MIT
dependencies:
- { name: grav, version: '>=1.7.41' }
- { name: grav, version: ">=1.7.49" }
form:
validation: strict
@ -145,7 +145,7 @@ form:
size: large
label: PLUGIN_FORM.DESTINATION
help: PLUGIN_FORM.DESTINATION_HELP
default: '@self'
default: "@self"
files.accept:
type: selectize
size: large
@ -155,7 +155,7 @@ form:
default:
- image/*
validate:
type: commalist
type: commalist
files.filesize:
type: text
label: PLUGIN_FORM.FILESIZE
@ -213,12 +213,12 @@ form:
type: text
label: PLUGIN_FORM.RECAPTCHA_SITE_KEY
help: PLUGIN_FORM.RECAPTCHA_SITE_KEY_HELP
default: ''
default: ""
recaptcha.secret_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SECRET_KEY
help: PLUGIN_FORM.RECAPTCHA_SECRET_KEY_HELP
default: ''
default: ""
turnstile_captcha:
type: section
@ -236,22 +236,90 @@ form:
type: text
label: PLUGIN_FORM.RECAPTCHA_SITE_KEY
help: PLUGIN_FORM.RECAPTCHA_SITE_KEY_HELP
default: ''
default: ""
turnstile.secret_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SECRET_KEY
help: PLUGIN_FORM.RECAPTCHA_SECRET_KEY_HELP
default: ''
default: ""
basic_captcha:
type: section
title: PLUGIN_FORM.BASIC_CAPTCHA
fields:
basic_captcha.image.width:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_WIDTH
default: 135
append: px
size: small
validate:
min: 100
max: 500
type: number
basic_captcha.image.height:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_HEIGHT
default: 40
append: px
size: small
validate:
min: 30
max: 200
type: number
basic_captcha.chars.font:
type: select
label: PLUGIN_FORM.BASIC_CAPTCHA_FONT
default: zxx-noise.ttf
options:
"zxx-noise.ttf": zxx-Noise
"zxx-xed.ttf": zxx-Xed
"zxx-camo.ttf": zxx-Camo
"zxx-sans.ttf": zxx-Sans
basic_captcha.chars.size:
type: range
label: PLUGIN_FORM.BASIC_CAPTCHA_SIZE
default: 24
append: px
validate:
min: 12
max: 32
step: 2
basic_captcha.chars.bg:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_BG_COLOR
default: "#ffffff"
basic_captcha.chars.text:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_TEXT_COLOR
default: "#000000"
basic_captcha.chars.start_x:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_X
default: 5
append: px
size: small
validate:
min: 0
type: number
basic_captcha.chars.start_y:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_Y
default: 30
append: px
size: small
validate:
min: 0
type: number
basic_captcha.type:
type: elements
label: PLUGIN_FORM.BASIC_CAPTCHA_TYPE
default: 'characters'
default: "characters"
size: medium
options:
characters: Random Characters
@ -268,70 +336,7 @@ form:
min: 4
max: 12
append: characters
basic_captcha.chars.font:
type: select
label: PLUGIN_FORM.BASIC_CAPTCHA_FONT
default: zxx-noise.ttf
options:
'zxx-noise.ttf': zxx-Noise
'zxx-xed.ttf': zxx-Xed
'zxx-camo.ttf': zxx-Camo
'zxx-sans.ttf': zxx-Sans
basic_captcha.chars.size:
type: range
label: PLUGIN_FORM.BASIC_CAPTCHA_SIZE
default: 24
append: px
validate:
min: 12
max: 32
step: 2
basic_captcha.chars.bg:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_BG_COLOR
default: '#ffffff'
basic_captcha.chars.text:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_TEXT_COLOR
default: '#000000'
basic_captcha.chars.start_x:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_X
default: 5
append: px
size: small
validate:
min: 0
type: number
basic_captcha.chars.start_y:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_Y
default: 30
append: px
size: small
validate:
min: 0
type: number
basic_captcha.chars.box_width:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_WIDTH
default: 135
append: px
size: small
validate:
min: 0
type: number
basic_captcha.chars.box_height:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_HEIGHT
default: 40
append: px
size: small
validate:
min: 0
type: number
math:
type: element
fields:
@ -355,14 +360,14 @@ form:
type: selectize
selectize:
options:
- value: '+'
text: '+ Addition'
- value: '-'
text: '- Subtraction'
- value: '*'
text: 'x Multiplication'
- value: '/'
text: '/ Division'
- value: "+"
text: "+ Addition"
- value: "-"
text: "- Subtraction"
- value: "*"
text: "x Multiplication"
- value: "/"
text: "/ Division"
label: PLUGIN_FORM.BASIC_CAPTCHA_MATH_OPERATORS
validate:
type: commalist

View file

@ -1,122 +0,0 @@
<?php
namespace Grav\Plugin\Form;
use GdImage;
use Grav\Common\Grav;
class BasicCaptcha
{
protected $session = null;
protected $key = 'basic_captcha_code';
public function __construct()
{
$this->session = Grav::instance()['session'];
}
public function getCaptchaCode($length = null): string
{
$config = Grav::instance()['config']->get('plugins.form.basic_captcha');
$type = $config['type'] ?? 'characters';
if ($type == 'math') {
$min = $config['math']['min'] ?? 1;
$max = $config['math']['max'] ?? 12;
$operators = $config['math']['operators'] ?? ['+','-','*'];
$first_num = random_int($min, $max);
$second_num = random_int($min, $max);
$operator = $operators[array_rand($operators)];
// calculator
if ($operator === '-') {
if ($first_num < $second_num) {
$result = "$second_num - $first_num";
$captcha_code = $second_num - $first_num;
} else {
$result = "$first_num-$second_num";
$captcha_code = $first_num - $second_num;
}
} elseif ($operator === '*') {
$result = "{$first_num} x {$second_num}";
$captcha_code = $first_num * $second_num;
} elseif ($operator === '/') {
$result = "$first_num / second_num";
$captcha_code = $first_num / $second_num;
} elseif ($operator === '+') {
$result = "$first_num + $second_num";
$captcha_code = $first_num + $second_num;
}
} else {
if ($length === null) {
$length = $config['chars']['length'] ?? 6;
}
$random_alpha = md5(random_bytes(64));
$captcha_code = substr($random_alpha, 0, $length);
$result = $captcha_code;
}
$this->setSession($this->key, $captcha_code);
return $result;
}
public function setSession($key, $value): void
{
$this->session->$key = $value;
}
public function getSession($key = null): ?string
{
if ($key === null) {
$key = $this->key;
}
return $this->session->$key ?? null;
}
public function createCaptchaImage($captcha_code)
{
$config = Grav::instance()['config']->get('plugins.form.basic_captcha');
$font = $config['chars']['font'] ?? 'zxx-xed.ttf';
$target_layer = imagecreatetruecolor($config['chars']['box_width'], $config['chars']['box_height']);
$bg = $this->hexToRgb($config['chars']['bg'] ?? '#ffffff');
$text = $this->hexToRgb($config['chars']['text'] ?? '#000000');
$captcha_background = imagecolorallocate($target_layer, $bg[0], $bg[1], $bg[2]);
$captcha_text_color = imagecolorallocate($target_layer, $text[0], $text[1], $text[2]);
$font_path = __DIR__ . '/../fonts/' . $font;
imagefill($target_layer, 0, 0, $captcha_background);
imagefttext($target_layer, $config['chars']['size'], 0, $config['chars']['start_x'], $config['chars']['start_y'], $captcha_text_color, $font_path, $captcha_code);
return $target_layer;
}
public function renderCaptchaImage($imageData): void
{
header("Content-type: image/jpeg");
imagejpeg($imageData);
}
public function validateCaptcha($formData): bool
{
$isValid = false;
$capchaSessionData = $this->getSession();
if ($capchaSessionData == $formData) {
$isValid = true;
}
return $isValid;
}
private function hexToRgb($hex): array
{
return sscanf($hex, "#%02x%02x%02x");
}
}

View file

@ -0,0 +1,596 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
class BasicCaptcha
{
protected $session = null;
protected $key = 'basic_captcha_value';
protected $typeKey = 'basic_captcha_type';
protected $config = null;
public function __construct($fieldConfig = null)
{
$this->session = Grav::instance()['session'];
// Load global configuration
$globalConfig = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
// Merge field-specific config with global config
if ($fieldConfig && is_array($fieldConfig)) {
$this->config = array_replace_recursive($globalConfig, $fieldConfig);
} else {
$this->config = $globalConfig;
}
}
public function getCaptchaCode($length = null): string
{
// Support both 'type' (from global config) and 'captcha_type' (from field config)
$type = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
// Store the captcha type in session for validation
$this->setSession($this->typeKey, $type);
switch ($type) {
case 'dotcount':
return $this->getDotCountCaptcha($this->config);
case 'position':
return $this->getPositionCaptcha($this->config);
case 'math':
return $this->getMathCaptcha($this->config);
case 'characters':
default:
return $this->getCharactersCaptcha($this->config, $length);
}
}
/**
* Creates a dot counting captcha - user has to count dots of a specific color
*/
protected function getDotCountCaptcha($config): string
{
// Define colors with names
$colors = [
'red' => [255, 0, 0],
'blue' => [0, 0, 255],
'green' => [0, 128, 0],
'yellow' => [255, 255, 0],
'purple' => [128, 0, 128],
'orange' => [255, 165, 0]
];
// Pick a random color to count
$colorNames = array_keys($colors);
$targetColorName = $colorNames[array_rand($colorNames)];
$targetColor = $colors[$targetColorName];
// Generate a random number of dots for the target color (between 5-10)
$targetCount = mt_rand(5, 10);
// Store the expected answer
$this->setSession($this->key, (string) $targetCount);
// Return description text
return "count_dots|{$targetColorName}|".implode(',', $targetColor);
}
/**
* Creates a position-based captcha - user has to identify position of a symbol
*/
protected function getPositionCaptcha($config): string
{
// Define possible symbols - using simple ASCII characters
$symbols = ['*', '+', '$', '#', '@', '!', '?', '%', '&', '='];
// Define positions - simpler options
$positions = ['top', 'bottom', 'left', 'right', 'center'];
// Pick a random symbol and position
$targetSymbol = $symbols[array_rand($symbols)];
$targetPosition = $positions[array_rand($positions)];
// Store the expected answer
$this->setSession($this->key, $targetPosition);
// Return the instruction and symbol
return "position|{$targetSymbol}|{$targetPosition}";
}
/**
* Creates a math-based captcha
*/
protected function getMathCaptcha($config): string
{
$min = $config['math']['min'] ?? 1;
$max = $config['math']['max'] ?? 12;
$operators = $config['math']['operators'] ?? ['+', '-', '*'];
$first_num = random_int($min, $max);
$second_num = random_int($min, $max);
$operator = $operators[array_rand($operators)];
// Calculator
if ($operator === '-') {
if ($first_num < $second_num) {
$result = "$second_num - $first_num";
$captcha_code = $second_num - $first_num;
} else {
$result = "$first_num - $second_num";
$captcha_code = $first_num - $second_num;
}
} elseif ($operator === '*') {
$result = "{$first_num} x {$second_num}";
$captcha_code = $first_num * $second_num;
} elseif ($operator === '+') {
$result = "$first_num + $second_num";
$captcha_code = $first_num + $second_num;
}
$this->setSession($this->key, (string) $captcha_code);
return $result;
}
/**
* Creates a character-based captcha
*/
protected function getCharactersCaptcha($config, $length = null): string
{
if ($length === null) {
$length = $config['chars']['length'] ?? 6;
}
// Use more complex character set with mixed case and exclude similar-looking characters
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
$captcha_code = '';
// Generate random characters
for ($i = 0; $i < $length; $i++) {
$captcha_code .= $chars[random_int(0, strlen($chars) - 1)];
}
$this->setSession($this->key, $captcha_code);
return $captcha_code;
}
public function setSession($key, $value): void
{
$this->session->$key = $value;
}
public function getSession($key = null): ?string
{
if ($key === null) {
$key = $this->key;
}
return $this->session->$key ?? null;
}
/**
* Create captcha image based on the type
*/
public function createCaptchaImage($captcha_code)
{
// Determine image dimensions based on type
$isCharacterCaptcha = false;
if (strpos($captcha_code, '|') === false && !preg_match('/[\+\-x]/', $captcha_code)) {
$isCharacterCaptcha = true;
}
// Use box_width/box_height for character captchas if specified, otherwise use default image dimensions
if ($isCharacterCaptcha && isset($this->config['chars']['box_width'])) {
$width = $this->config['chars']['box_width'];
} else {
$width = $this->config['image']['width'] ?? 135;
}
if ($isCharacterCaptcha && isset($this->config['chars']['box_height'])) {
$height = $this->config['chars']['box_height'];
} else {
$height = $this->config['image']['height'] ?? 40;
}
// Create a blank image
$image = imagecreatetruecolor($width, $height);
// Set background color (support both image.bg and chars.bg for character captchas)
$bgColor = '#ffffff';
if ($isCharacterCaptcha && isset($this->config['chars']['bg'])) {
$bgColor = $this->config['chars']['bg'];
} elseif (isset($this->config['image']['bg'])) {
$bgColor = $this->config['image']['bg'];
}
$bg = $this->hexToRgb($bgColor);
$backgroundColor = imagecolorallocate($image, $bg[0], $bg[1], $bg[2]);
imagefill($image, 0, 0, $backgroundColor);
// Parse the captcha code to determine type
if (strpos($captcha_code, '|') !== false) {
$parts = explode('|', $captcha_code);
$type = $parts[0];
switch ($type) {
case 'count_dots':
return $this->createDotCountImage($image, $parts, $this->config);
case 'position':
return $this->createPositionImage($image, $parts, $this->config);
}
} else {
// Assume it's a character or math captcha if no type indicator
if (preg_match('/[\+\-x]/', $captcha_code)) {
return $this->createMathImage($image, $captcha_code, $this->config);
} else {
return $this->createCharacterImage($image, $captcha_code, $this->config);
}
}
return $image;
}
/**
* Create image for dot counting captcha
*/
protected function createDotCountImage($image, $parts, $config)
{
$colorName = $parts[1];
$targetColorRGB = explode(',', $parts[2]);
$width = imagesx($image);
$height = imagesy($image);
// Allocate target color
$targetColor = imagecolorallocate($image, $targetColorRGB[0], $targetColorRGB[1], $targetColorRGB[2]);
// Create other distraction colors
$distractionColors = [];
$colorOptions = [
[255, 0, 0], // red
[0, 0, 255], // blue
[0, 128, 0], // green
[255, 255, 0], // yellow
[128, 0, 128], // purple
[255, 165, 0] // orange
];
foreach ($colorOptions as $rgb) {
if ($rgb[0] != $targetColorRGB[0] || $rgb[1] != $targetColorRGB[1] || $rgb[2] != $targetColorRGB[2]) {
$distractionColors[] = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
}
}
// Get target count from session
$targetCount = (int) $this->getSession();
// Draw instruction text
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$black = imagecolorallocate($image, 0, 0, 0);
imagettftext($image, 10, 0, 5, 15, $black, $fontPath, "Count {$colorName}:");
// Simplified approach to prevent overlapping
// Divide the image into a grid and place one dot per cell
$gridCells = [];
$gridRows = 2;
$gridCols = 4;
// Build available grid cells
for ($y = 0; $y < $gridRows; $y++) {
for ($x = 0; $x < $gridCols; $x++) {
$gridCells[] = [$x, $y];
}
}
// Shuffle grid cells for random placement
shuffle($gridCells);
// Calculate cell dimensions
$cellWidth = ($width - 20) / $gridCols;
$cellHeight = ($height - 20) / $gridRows;
// Dot size for better visibility
$dotSize = 8;
// Draw target dots first (taking the first N cells)
for ($i = 0; $i < $targetCount && $i < count($gridCells); $i++) {
$cell = $gridCells[$i];
$gridX = $cell[0];
$gridY = $cell[1];
// Calculate center position of cell with small random offset
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
// Draw the dot
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $targetColor);
// Add a small border for better contrast
imageellipse($image, $x, $y, $dotSize + 2, $dotSize + 2, $black);
}
// Draw distraction dots using remaining grid cells
$distractionCount = min(mt_rand(8, 15), count($gridCells) - $targetCount);
for ($i = 0; $i < $distractionCount; $i++) {
// Get the next available cell
$cellIndex = $targetCount + $i;
if ($cellIndex >= count($gridCells)) {
break; // No more cells available
}
$cell = $gridCells[$cellIndex];
$gridX = $cell[0];
$gridY = $cell[1];
// Calculate center position of cell with small random offset
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
// Draw the dot with a random distraction color
$color = $distractionColors[array_rand($distractionColors)];
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $color);
}
// Add subtle grid lines to help with counting
$lightGray = imagecolorallocate($image, 230, 230, 230);
for ($i = 1; $i < $gridCols; $i++) {
imageline($image, 10 + $i * $cellWidth, 20, 10 + $i * $cellWidth, $height - 5, $lightGray);
}
for ($i = 1; $i < $gridRows; $i++) {
imageline($image, 10, 20 + $i * $cellHeight, $width - 10, 20 + $i * $cellHeight, $lightGray);
}
// Add minimal noise
$this->addImageNoise($image, 15);
return $image;
}
/**
* Create image for position captcha
*/
protected function createPositionImage($image, $parts, $config)
{
$symbol = $parts[1];
$position = $parts[2];
$width = imagesx($image);
$height = imagesy($image);
// Allocate colors
$black = imagecolorallocate($image, 0, 0, 0);
$red = imagecolorallocate($image, 255, 0, 0);
// Draw instruction text
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
imagettftext($image, 9, 0, 5, 15, $black, $fontPath, "Position of symbol?");
// Determine symbol position based on the target position
$symbolX = $width / 2;
$symbolY = $height / 2;
switch ($position) {
case 'top':
$symbolX = $width / 2;
$symbolY = 20;
break;
case 'bottom':
$symbolX = $width / 2;
$symbolY = $height - 10;
break;
case 'left':
$symbolX = 20;
$symbolY = $height / 2;
break;
case 'right':
$symbolX = $width - 20;
$symbolY = $height / 2;
break;
case 'center':
$symbolX = $width / 2;
$symbolY = $height / 2;
break;
}
// Draw the symbol - make it larger and in red for visibility
imagettftext($image, 20, 0, $symbolX - 8, $symbolY + 8, $red, $fontPath, $symbol);
// Draw a grid to make positions clearer
$gray = imagecolorallocate($image, 200, 200, 200);
imageline($image, $width / 2, 15, $width / 2, $height - 5, $gray);
imageline($image, 5, $height / 2, $width - 5, $height / 2, $gray);
// Add minimal noise
$this->addImageNoise($image, 10);
return $image;
}
/**
* Create image for math captcha
*/
protected function createMathImage($image, $mathExpression, $config)
{
$width = imagesx($image);
$height = imagesy($image);
// Get font and colors
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$textColor = imagecolorallocate($image, 0, 0, 0);
// Draw the math expression
$fontSize = 16;
$textBox = imagettfbbox($fontSize, 0, $fontPath, $mathExpression);
$textWidth = $textBox[2] - $textBox[0];
$textHeight = $textBox[1] - $textBox[7];
$textX = ($width - $textWidth) / 2;
$textY = ($height + $textHeight) / 2;
imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontPath, $mathExpression);
// Add visual noise and distortions to prevent OCR
$this->addImageNoise($image, 25);
$this->addWaveDistortion($image);
return $image;
}
/**
* Create image for character captcha
*/
protected function createCharacterImage($image, $captcha_code, $config)
{
$width = imagesx($image);
$height = imagesy($image);
// Get font settings with support for custom box dimensions, position, and colors
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$fontSize = $config['chars']['size'] ?? 16;
// Support custom text color (defaults to black)
$textColorHex = $config['chars']['text'] ?? '#000000';
$textRgb = $this->hexToRgb($textColorHex);
$textColor = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
// Support custom start position (useful for fine-tuning text placement)
$startX = $config['chars']['start_x'] ?? ($width / (strlen($captcha_code) + 2));
$baseY = $config['chars']['start_y'] ?? ($height / 2 + 5);
// Draw each character with random rotation and position
$charWidth = $width / (strlen($captcha_code) + 2);
for ($i = 0; $i < strlen($captcha_code); $i++) {
$char = $captcha_code[$i];
$angle = mt_rand(-15, 15); // Random rotation
// Random vertical position with custom base Y
$y = $baseY + mt_rand(-5, 5);
imagettftext($image, $fontSize, $angle, $startX, $y, $textColor, $fontPath, $char);
// Move to next character position with some randomness
$startX += $charWidth + mt_rand(-5, 5);
}
// Add visual noise and distortions
$this->addImageNoise($image, 25);
$this->addWaveDistortion($image);
return $image;
}
/**
* Add random noise to the image
*/
protected function addImageNoise($image, $density = 100)
{
$width = imagesx($image);
$height = imagesy($image);
// For performance, reduce density
$density = min($density, 30);
// Add random dots
for ($i = 0; $i < $density; $i++) {
$x = mt_rand(0, $width - 1);
$y = mt_rand(0, $height - 1);
$shade = mt_rand(150, 200);
$color = imagecolorallocate($image, $shade, $shade, $shade);
imagesetpixel($image, $x, $y, $color);
}
// Add a few random lines
$lineCount = min(3, mt_rand(2, 3));
for ($i = 0; $i < $lineCount; $i++) {
$x1 = mt_rand(0, $width / 4);
$y1 = mt_rand(0, $height - 1);
$x2 = mt_rand(3 * $width / 4, $width - 1);
$y2 = mt_rand(0, $height - 1);
$shade = mt_rand(150, 200);
$color = imagecolorallocate($image, $shade, $shade, $shade);
imageline($image, $x1, $y1, $x2, $y2, $color);
}
}
/**
* Add wave distortion to the image
*/
protected function addWaveDistortion($image)
{
$width = imagesx($image);
$height = imagesy($image);
// Create temporary image
$temp = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($temp, 255, 255, 255);
imagefill($temp, 0, 0, $bg);
// Copy original to temp
imagecopy($temp, $image, 0, 0, 0, 0, $width, $height);
// Clear original image
$bg = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $bg);
// Apply simplified wave distortion
$amplitude = mt_rand(1, 2);
$period = mt_rand(10, 15);
// Process only every 2nd pixel for better performance
for ($x = 0; $x < $width; $x += 2) {
$wave = sin($x / $period) * $amplitude;
for ($y = 0; $y < $height; $y += 2) {
$yp = $y + $wave;
if ($yp >= 0 && $yp < $height) {
$color = imagecolorat($temp, $x, $yp);
imagesetpixel($image, $x, $y, $color);
// Fill adjacent pixel for better performance
if ($x + 1 < $width && $y + 1 < $height) {
imagesetpixel($image, $x + 1, $y, $color);
}
}
}
}
imagedestroy($temp);
}
public function renderCaptchaImage($imageData): void
{
header("Content-type: image/jpeg");
imagejpeg($imageData);
}
public function validateCaptcha($formData): bool
{
$isValid = false;
$capchaSessionData = $this->getSession();
// Make validation case-insensitive
if (strtolower((string) $capchaSessionData) == strtolower((string) $formData)) {
$isValid = true;
}
// Debug validation if enabled
$grav = Grav::instance();
if ($grav['config']->get('plugins.form.basic_captcha.debug', false)) {
$grav['log']->debug("Captcha Validation - Expected: '{$capchaSessionData}', Got: '{$formData}', Result: ".
($isValid ? 'valid' : 'invalid'));
}
// Regenerate a new captcha after validation
$this->setSession($this->key, null);
return $isValid;
}
private function hexToRgb($hex): array
{
return sscanf($hex, "#%02x%02x%02x");
}
}

View file

@ -0,0 +1,134 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
/**
* Basic Captcha provider implementation
*/
class BasicCaptchaProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$session = $grav['session'];
try {
// Get the expected answer from session
// Make sure to use the same session key that the image generation code uses
$expectedValue = $session->basic_captcha_value ?? null; // Changed from basic_captcha to basic_captcha_value
// Get the captcha type from session (stored during generation)
$captchaType = $session->basic_captcha_type ?? null;
// Get the user's answer
$userValue = $form['basic-captcha'] ?? null;
if (!$expectedValue) {
return [
'success' => false,
'error' => 'missing-session-data',
'details' => ['error' => 'No captcha value found in session']
];
}
if (!$userValue) {
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'User did not enter a captcha value']
];
}
// Compare the values based on the type stored in session
// If type is not in session, try to infer from global/field config
if (!$captchaType) {
$captchaType = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
}
if ($captchaType === 'characters') {
$isValid = strtolower((string)$userValue) === strtolower((string)$expectedValue);
} else {
// For math, dotcount, position - ensure both are treated as integers or exact match
$isValid = (int)$userValue === (int)$expectedValue;
}
if (!$isValid) {
return [
'success' => false,
'error' => 'validation-failed',
'details' => [
'expected' => $expectedValue,
'received' => $userValue
]
];
}
// Clear the session values to prevent reuse
$session->basic_captcha_value = null;
$session->basic_captcha_type = null;
return [
'success' => true
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$grav = Grav::instance();
$session = $grav['session'];
// Merge field-level configuration with global defaults
$fieldConfig = array_replace_recursive($this->config, $field);
// Remove non-config keys from field array
unset($fieldConfig['type'], $fieldConfig['label'], $fieldConfig['placeholder'],
$fieldConfig['validate'], $fieldConfig['name'], $fieldConfig['classes']);
// Generate unique field ID for this form/field combination
$fieldId = md5($formId . '_basic_captcha_' . ($field['name'] ?? 'default'));
// Store field configuration in session for image generation
$session->{"basic_captcha_config_{$fieldId}"} = $fieldConfig;
$captchaType = $fieldConfig['type'] ?? 'math';
return [
'provider' => 'basic-captcha',
'type' => $captchaType,
'imageUrl' => "/forms-basic-captcha-image.jpg?field={$fieldId}",
'refreshable' => true,
'containerId' => "basic-captcha-{$formId}",
'fieldId' => $fieldId
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
return 'forms/fields/basic-captcha/basic-captcha.html.twig';
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
/**
* Factory for captcha providers
*/
class CaptchaFactory
{
/** @var array */
protected static $providers = [];
/**
* Register a captcha provider
*
* @param string $name Provider name
* @param string|CaptchaProviderInterface $provider Provider class or instance
* @return void
*/
public static function registerProvider(string $name, $provider): void
{
// If it's a class name, instantiate it
if (is_string($provider) && class_exists($provider)) {
$provider = new $provider();
}
if (!$provider instanceof CaptchaProviderInterface) {
Grav::instance()['log']->error("Cannot register captcha provider '{$name}': Provider must implement CaptchaProviderInterface");
return;
}
self::$providers[$name] = $provider;
// Grav::instance()['log']->debug("Registered captcha provider: {$name}");
}
/**
* Check if a provider is registered
*
* @param string $name Provider name
* @return bool
*/
public static function hasProvider(string $name): bool
{
return isset(self::$providers[$name]);
}
/**
* Get a provider by name
*
* @param string $name Provider name
* @return CaptchaProviderInterface|null Provider instance or null if not found
*/
public static function getProvider(string $name): ?CaptchaProviderInterface
{
return self::$providers[$name] ?? null;
}
/**
* Get all registered providers
*
* @return array
*/
public static function getProviders(): array
{
return self::$providers;
}
/**
* Register all default captcha providers
*
* @return void
*/
public static function registerDefaultProviders(): void
{
// Register built-in providers
self::registerProvider('recaptcha', new ReCaptchaProvider());
self::registerProvider('turnstile', new TurnstileProvider());
self::registerProvider('basic-captcha', new BasicCaptchaProvider());
// Log the registration
// Grav::instance()['log']->debug('Registered default captcha providers');
}
}

View file

@ -0,0 +1,244 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Plugin\Form\Form;
use RocketTheme\Toolbox\Event\Event;
/**
* Central manager for captcha processing
*/
class CaptchaManager
{
/**
* Initialize the captcha manager
*
* @return void
*/
public static function initialize(): void
{
// Register all default captcha providers
CaptchaFactory::registerDefaultProviders();
// Allow plugins to register custom captcha providers
Grav::instance()->fireEvent('onFormRegisterCaptchaProviders');
}
/**
* Process a captcha validation
*
* @param Form $form The form to validate
* @param array|null $params Optional parameters
* @return bool True if validation succeeded
*/
public static function validateCaptcha(Form $form, $params = null): bool
{
// Handle case where $params is a boolean (backward compatibility)
if (!is_array($params)) {
$params = [];
}
// --- 1. Find the captcha field in the form ---
$captchaField = null;
$providerName = null;
$formFields = $form->value()->blueprints()->get('form/fields');
foreach ($formFields as $fieldName => $fieldDef) {
$fieldType = $fieldDef['type'] ?? null;
// Check for modern captcha type with provider
if ($fieldType === 'captcha') {
$captchaField = $fieldDef;
$providerName = $fieldDef['provider'] ?? 'recaptcha';
break;
}
// Check for legacy type-based providers (like basic-captcha and turnstile)
// This is for backward compatibility
elseif ($fieldType && CaptchaFactory::hasProvider($fieldType)) {
$captchaField = $fieldDef;
$providerName = $fieldType;
break;
}
}
if (!$captchaField || !$providerName) {
// No captcha field found or no provider specified
return true;
}
// --- 2. Get provider and validate ---
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
Grav::instance()['log']->error("Form Captcha: Unknown provider '{$providerName}' requested");
return false;
}
// Allow plugins to modify the validation parameters
$validationEvent = new Event([
'form' => $form,
'field' => $captchaField,
'provider' => $providerName,
'params' => $params
]);
Grav::instance()->fireEvent('onBeforeCaptchaValidation', $validationEvent);
$params = $validationEvent['params'];
// Validate using the provider
try {
$result = $provider->validate($form->value()->toArray(), $params);
if (!$result['success']) {
$logDetails = $result['details'] ?? [];
$errorMessage = self::getErrorMessage($captchaField, $result['error'] ?? 'validation-failed', $providerName);
// Fire validation error event
Grav::instance()->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $errorMessage,
'provider' => $providerName
]));
// Log the failure
$uri = Grav::instance()['uri'];
Grav::instance()['log']->warning(
"Form Captcha ({$providerName}) validation failed: [{$uri->route()}] Details: " .
json_encode($logDetails)
);
return false;
}
// Log success
Grav::instance()['log']->info("Form Captcha ({$providerName}) validation successful for form: " . $form->name);
// Fire success event
Grav::instance()->fireEvent('onCaptchaValidationSuccess', new Event([
'form' => $form,
'provider' => $providerName
]));
return true;
} catch (\Exception $e) {
// Handle other errors
Grav::instance()['log']->error("Form Captcha ({$providerName}) validation error: " . $e->getMessage());
$errorMessage = Grav::instance()['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
Grav::instance()->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $errorMessage,
'provider' => $providerName,
'exception' => $e
]));
return false;
}
}
/**
* Get appropriate error message based on error code and field definition
*
* @param array $field Field definition
* @param string $errorCode Error code
* @param string $provider Provider name
* @return string
*/
protected static function getErrorMessage(array $field, string $errorCode, string $provider): string
{
$grav = Grav::instance();
// First check for specific message in field definition
if (isset($field['captcha_not_validated'])) {
return $field['captcha_not_validated'];
}
// Then check for specific error code message
if ($errorCode === 'missing-input-response') {
return $grav['language']->translate('PLUGIN_FORM.ERROR_CAPTCHA_NOT_COMPLETED');
}
// Allow providers to supply custom error messages via event
$messageEvent = new Event([
'provider' => $provider,
'errorCode' => $errorCode,
'field' => $field,
'message' => null
]);
$grav->fireEvent('onCaptchaErrorMessage', $messageEvent);
if ($messageEvent['message']) {
return $messageEvent['message'];
}
// Finally fall back to generic message
return $grav['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
}
/**
* Get client-side initialization data for a captcha field
*
* @param string $formId Form ID
* @param array $field Field definition
* @return array Client properties
*/
public static function getClientProperties(string $formId, array $field): array
{
$providerName = $field['provider'] ?? null;
// Handle legacy field types as providers
if (!$providerName && isset($field['type'])) {
$fieldType = $field['type'];
if (CaptchaFactory::hasProvider($fieldType)) {
$providerName = $fieldType;
}
}
if (!$providerName) {
// Default to recaptcha for backward compatibility
$providerName = 'recaptcha';
}
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
return [
'provider' => $providerName,
'error' => "Unknown captcha provider: {$providerName}"
];
}
return $provider->getClientProperties($formId, $field);
}
/**
* Get template name for a captcha field
*
* @param array $field Field definition
* @return string Template name
*/
public static function getTemplateName(array $field): string
{
$providerName = $field['provider'] ?? null;
// Handle legacy field types as providers
if (!$providerName && isset($field['type'])) {
$fieldType = $field['type'];
if (CaptchaFactory::hasProvider($fieldType)) {
$providerName = $fieldType;
}
}
if (!$providerName) {
// Default to recaptcha for backward compatibility
$providerName = 'recaptcha';
}
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
return 'forms/fields/captcha/default.html.twig';
}
return $provider->getTemplateName();
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Grav\Plugin\Form\Captcha;
/**
* Interface for captcha providers
*/
interface CaptchaProviderInterface
{
/**
* Validate a captcha response
*
* @param array $form Form data array
* @param array $params Optional parameters
* @return array Validation result with 'success' key and optional 'error' and 'details' keys
*/
public function validate(array $form, array $params = []): array;
/**
* Get client-side properties for the captcha
*
* @param string $formId Form ID
* @param array $field Field definition
* @return array Client properties
*/
public function getClientProperties(string $formId, array $field): array;
/**
* Get the template name for the captcha field
*
* @return string
*/
public function getTemplateName(): string;
}

View file

@ -0,0 +1,252 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Common\Uri;
/**
* Google reCAPTCHA provider implementation
*/
class ReCaptchaProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.recaptcha', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$uri = $grav['uri'];
$ip = Uri::ip();
$hostname = $uri->host();
try {
$secretKey = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ??
$this->config['secret_key'] ?? null;
$defaultVersion = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
$version = $this->normalizeVersion($params['recaptcha_version'] ?? $defaultVersion);
$payloadVersion = $this->detectVersionFromPayload($form);
if ($payloadVersion !== null) {
$version = $payloadVersion;
}
if (!$secretKey) {
throw new \RuntimeException("reCAPTCHA secret key not configured.");
}
$requestMethod = extension_loaded('curl') ? new \ReCaptcha\RequestMethod\CurlPost() : null;
$recaptcha = new \ReCaptcha\ReCaptcha($secretKey, $requestMethod);
// Handle V3
if ($version === '3') {
// For V3, look for token in both top level and data[] structure
$token = $form['token'] ?? ($form['data']['token'] ?? null);
$action = $form['action'] ?? ($form['data']['action'] ?? null);
if (!$token) {
$grav['log']->debug('reCAPTCHA validation failed: token missing for v3');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response', 'version' => 'v3']
];
}
$recaptcha->setExpectedHostname($hostname);
// Set action if provided
if ($action) {
$recaptcha->setExpectedAction($action);
}
// Set score threshold
$recaptcha->setScoreThreshold($this->config['score_threshold'] ?? 0.5);
}
// Handle V2 (both checkbox and invisible)
else {
// For V2, look for standard response parameter
$token = $form['g-recaptcha-response'] ?? ($form['data']['g-recaptcha-response'] ?? null);
if (!$token) {
$post = $grav['uri']->post();
if (is_array($post)) {
if (isset($post['g-recaptcha-response'])) {
$token = $post['g-recaptcha-response'];
} elseif (isset($post['g_recaptcha_response'])) {
$token = $post['g_recaptcha_response'];
} elseif (isset($post['data']) && is_array($post['data'])) {
if (isset($post['data']['g-recaptcha-response'])) {
$token = $post['data']['g-recaptcha-response'];
} elseif (isset($post['data']['g_recaptcha_response'])) {
$token = $post['data']['g_recaptcha_response'];
}
}
}
}
if (!$token) {
$grav['log']->debug('reCAPTCHA validation failed: g-recaptcha-response missing for v2');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response', 'version' => 'v2']
];
}
$recaptcha->setExpectedHostname($hostname);
}
// Log validation attempt
$grav['log']->debug('reCAPTCHA validation attempt for version ' . $version);
$validationResponseObject = $recaptcha->verify($token, $ip);
$isValid = $validationResponseObject->isSuccess();
if (!$isValid) {
$errorCodes = $validationResponseObject->getErrorCodes();
$grav['log']->debug('reCAPTCHA validation failed: ' . json_encode($errorCodes));
return [
'success' => false,
'error' => 'validation-failed',
'details' => ['error-codes' => $errorCodes, 'version' => $version]
];
}
// For V3, check if score is available and log it (helpful for debugging/tuning)
if ($version === '3' && method_exists($validationResponseObject, 'getScore')) {
$score = $validationResponseObject->getScore();
$grav['log']->debug('reCAPTCHA v3 validation successful with score: ' . $score);
} else {
$grav['log']->debug('reCAPTCHA validation successful');
}
return [
'success' => true
];
} catch (\Exception $e) {
$grav['log']->error('reCAPTCHA validation error: ' . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* Normalize version values to the internal format we use elsewhere.
*/
protected function normalizeVersion($version): string
{
if ($version === null || $version === '') {
return '2-checkbox';
}
if ($version === 3 || $version === '3') {
return '3';
}
if ($version === 2 || $version === '2') {
return '2-checkbox';
}
return (string) $version;
}
/**
* Infer the recaptcha version from the submitted payload when possible.
*/
protected function detectVersionFromPayload(array $form): ?string
{
$formData = isset($form['data']) && is_array($form['data']) ? $form['data'] : [];
$grav = Grav::instance();
$config = $grav['config'];
if ($config->get('plugins.form.debug')) {
try {
$grav['log']->debug('reCAPTCHA payload inspection', [
'top_keys' => array_keys($form),
'data_keys' => array_keys($formData),
]);
} catch (\Throwable $e) {
// Ignore logging issues, detection should continue.
}
}
if (array_key_exists('token', $form) || array_key_exists('token', $formData)) {
return '3';
}
if (array_key_exists('g-recaptcha-response', $form) || array_key_exists('g-recaptcha-response', $formData)) {
return '2-checkbox';
}
if (array_key_exists('g_recaptcha_response', $form) || array_key_exists('g_recaptcha_response', $formData)) {
// Support alternative key naming just in case
return '2-checkbox';
}
return null;
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$siteKey = $field['recaptcha_site_key'] ?? $this->config['site_key'] ?? null;
$theme = $field['recaptcha_theme'] ?? $this->config['theme'] ?? 'light';
$version = $this->normalizeVersion($field['recaptcha_version'] ?? $this->config['version'] ?? '2-checkbox');
// Determine which version we're using
$isV3 = $version === '3';
$isInvisible = $version === '2-invisible';
// Log the configuration to help with debugging
$grav = Grav::instance();
$grav['log']->debug("reCAPTCHA config for form {$formId}: version={$version}, siteKey=" .
(empty($siteKey) ? 'MISSING' : 'configured'));
return [
'provider' => 'recaptcha',
'siteKey' => $siteKey,
'theme' => $theme,
'version' => $version,
'isV3' => $isV3,
'isInvisible' => $isInvisible,
'containerId' => "g-recaptcha-{$formId}",
'scriptUrl' => "https://www.google.com/recaptcha/api.js" . ($isV3 ? '?render=' . $siteKey : ''),
'initFunctionName' => "initRecaptcha_{$formId}"
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
// Different templates based on version
$version = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
$isV3 = $version === '3';
$isInvisible = $version === '2-invisible';
if ($isV3) {
return 'forms/fields/recaptcha/recaptchav3.html.twig';
} elseif ($isInvisible) {
return 'forms/fields/recaptcha/recaptcha-invisible.html.twig';
}
return 'forms/fields/recaptcha/recaptcha.html.twig';
}
}

View file

@ -0,0 +1,134 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Common\Uri;
use Grav\Common\HTTP\Client;
/**
* Cloudflare Turnstile provider implementation
*/
class TurnstileProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.turnstile', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$uri = $grav['uri'];
$ip = Uri::ip();
$grav['log']->debug('Turnstile validation - entire form data: ' . json_encode(array_keys($form)));
try {
$secretKey = $params['turnstile_secret'] ??
$this->config['secret_key'] ?? null;
if (!$secretKey) {
$grav['log']->error("Turnstile secret key not configured.");
throw new \RuntimeException("Turnstile secret key not configured.");
}
// First check $_POST directly, then fallback to form data
$token = $_POST['cf-turnstile-response'] ?? null;
if (!$token) {
$token = $form['cf-turnstile-response'] ?? null;
}
// Log raw POST data for debugging
$grav['log']->debug('Turnstile validation - raw POST data keys: ' . json_encode(array_keys($_POST)));
$grav['log']->debug('Turnstile validation - token present: ' . ($token ? 'YES' : 'NO'));
if ($token) {
$grav['log']->debug('Turnstile token length: ' . strlen($token));
}
if (!$token) {
$grav['log']->warning('Turnstile validation failed: missing token response');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response']
];
}
$client = \Grav\Common\HTTP\Client::getClient();
$grav['log']->debug('Turnstile validation - calling API with token');
$response = $client->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $secretKey,
'response' => $token,
'remoteip' => $ip
]
]);
$statusCode = $response->getStatusCode();
$grav['log']->debug('Turnstile API response status: ' . $statusCode);
$content = $response->toArray();
$grav['log']->debug('Turnstile API response: ' . json_encode($content));
if (!isset($content['success'])) {
$grav['log']->error("Invalid response from Turnstile verification (missing 'success' key).");
throw new \RuntimeException("Invalid response from Turnstile verification (missing 'success' key).");
}
if (!$content['success']) {
$grav['log']->warning('Turnstile validation failed: ' . json_encode($content));
return [
'success' => false,
'error' => 'validation-failed',
'details' => ['error-codes' => $content['error-codes'] ?? ['validation-failed']]
];
}
$grav['log']->debug('Turnstile validation successful');
return [
'success' => true
];
} catch (\Exception $e) {
$grav['log']->error("Turnstile validation error: " . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$siteKey = $field['turnstile_site_key'] ?? $this->config['site_key'] ?? null;
$theme = $field['turnstile_theme'] ?? $this->config['theme'] ?? 'auto';
return [
'provider' => 'turnstile',
'siteKey' => $siteKey,
'theme' => $theme,
'containerId' => "cf-turnstile-{$formId}",
'scriptUrl' => "https://challenges.cloudflare.com/turnstile/v0/api.js",
'initFunctionName' => "initTurnstile_{$formId}"
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
return 'forms/fields/turnstile/turnstile.html.twig';
}
}

View file

@ -550,6 +550,10 @@ class Form implements FormInterface, ArrayAccess
$url = $uri->url;
$post = $uri->post();
if (!empty($post['__unique_form_id__'])) {
$this->setUniqueId($post['__unique_form_id__']);
}
$name = $post['name'] ?? null;
$task = $post['task'] ?? null;
@ -661,6 +665,7 @@ class Form implements FormInterface, ArrayAccess
// Handle file size limits
$settings->filesize *= self::BYTES_TO_MB; // 1024 * 1024 [MB in Bytes]
if ($settings->filesize > 0 && $upload['file']['size'] > $settings->filesize) {
$grav['log']->warning(sprintf('Form upload rejected: %s (%d bytes) exceeds limit %d bytes', $filename, $upload['file']['size'], $settings->filesize));
// json_response
return [
'status' => 'error',
@ -748,7 +753,7 @@ class Form implements FormInterface, ArrayAccess
* @param Language|null $language
* @return string File upload error message
*/
public function getFileUploadError(int $error, Language $language = null): string
public function getFileUploadError(int $error, ?Language $language = null): string
{
if (!$language) {
$grav = Grav::instance();
@ -892,6 +897,16 @@ class Form implements FormInterface, ArrayAccess
$this->data->merge($data);
}
if (!empty($post['__unique_form_id__'])) {
$this->setUniqueId($post['__unique_form_id__']);
}
// Ensure file field values are populated from the flash storage before validation.
$flash = $this->getFlash();
if ($flash->exists()) {
$this->setAllFiles($flash);
}
// Validate and filter data
try {
$grav->fireEvent('onFormPrepareValidation', new Event(['form' => $this]));
@ -899,17 +914,41 @@ class Form implements FormInterface, ArrayAccess
$this->data->validate();
$this->data->filter();
$grav->fireEvent('onFormValidationProcessed', new Event(['form' => $this]));
} catch (ValidationException $e) {
$this->status = 'error';
$event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => $e->getMessages()]);
$grav->fireEvent('onFormValidationError', $event);
if ($event->isPropagationStopped()) {
return;
// Add special handling for file/filepond fields
foreach ($this->fields as $field) {
// Don't restrict to just type=file, but also other file based as long as they have filesize && accept
if (isset($field['filesize']) && isset($field['accept']) &&
isset($field['validate']['required']) &&
$field['validate']['required']) {
// Get field name
$fieldName = $field['name'];
$fieldLabel = $field['label'] ?? $field['name'];
// Check if files exist in the session for this field
$flashObject = $this->getFlash();
if ($flashObject->exists()) {
$filesInField = $flashObject->getFilesByField($fieldName);
// If no files found, add validation error
if (empty($filesInField)) {
$this->setError("$fieldLabel " . $grav['language']->translate("PLUGIN_FORM.FIELD_REQUIRED"));
throw new ValidationException();
}
} else {
// No flash object with files found
$this->setError("$fieldLabel " . $grav['language']->translate("PLUGIN_FORM.FIELD_REQUIRED"));
throw new ValidationException();
}
}
}
} catch (RuntimeException $e) {
$grav->fireEvent('onFormValidationProcessed', new Event(['form' => $this]));
} catch (ValidationException | RuntimeException $e) {
$this->status = 'error';
$event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => []]);
$this->message = $this->message ?? $e->getMessage();
$this->messages = array_merge($this->messages, $e->getMessages());
$event = new Event(['form' => $this, 'message' => $this->message, 'messages' => $this->messages]);
$grav->fireEvent('onFormValidationError', $event);
if ($event->isPropagationStopped()) {
return;
@ -918,10 +957,11 @@ class Form implements FormInterface, ArrayAccess
$redirect = $redirect_code = null;
$process = $this->items['process'] ?? [];
$legacyUploads = !isset($process['upload']) || $process['upload'] !== false;
$legacyUploads = !isset($process['upload']) || $process['upload'] !== true;
if ($legacyUploads) {
$this->legacyUploads();
$this->copyFiles();
}
if (is_array($process)) {
@ -949,10 +989,6 @@ class Form implements FormInterface, ArrayAccess
}
}
if ($legacyUploads) {
$this->copyFiles();
}
$this->getFlash()->delete();
if ($redirect) {
@ -1235,6 +1271,10 @@ class Form implements FormInterface, ArrayAccess
$post = $uri->post();
$post['data'] = $this->decodeData($post['data'] ?? []);
if (!empty($post['__unique_form_id__'])) {
$this->setUniqueId($post['__unique_form_id__']);
}
if (empty($post['form-nonce']) || !Utils::verifyNonce($post['form-nonce'], 'form')) {
throw new RuntimeException('Bad Request: Nonce is missing or invalid', 400);
}
@ -1256,7 +1296,7 @@ class Form implements FormInterface, ArrayAccess
* @param string|null $field
* @return void
*/
protected function removeFlashUpload(string $filename, string $field = null)
protected function removeFlashUpload(string $filename, ?string $field = null)
{
$flash = $this->getFlash();
$flash->removeFile($filename, $field);

View file

@ -62,7 +62,7 @@ class Forms
* @param array|null $form
* @return FormInterface|null
*/
public function createPageForm(PageInterface $page, string $name = null, array $form = null): ?FormInterface
public function createPageForm(PageInterface $page, ?string $name = null, ?array $form = null): ?FormInterface
{
if (null === $form) {
[$name, $form] = $this->getPageParameters($page, $name);

View file

@ -143,7 +143,7 @@ class TwigExtension extends AbstractExtension
* @param string|null $default
* @return string[]
*/
public function includeFormField(string $type, $layouts = null, string $default = null): array
public function includeFormField(string $type, $layouts = null, ?string $default = null): array
{
$list = [];
foreach ((array)$layouts as $layout) {

View file

@ -7,9 +7,7 @@ use DateTime;
use Doctrine\Common\Cache\Cache;
use Exception;
use Grav\Common\Data\ValidationException;
use Grav\Common\Debugger;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\Page\Types;
@ -21,14 +19,11 @@ use Grav\Common\Yaml;
use Grav\Framework\Form\Interfaces\FormInterface;
use Grav\Framework\Psr7\Response;
use Grav\Framework\Route\Route;
use Grav\Plugin\Form\BasicCaptcha;
use Grav\Plugin\Form\Captcha\BasicCaptcha;
use Grav\Plugin\Form\Captcha\CaptchaManager;
use Grav\Plugin\Form\Form;
use Grav\Plugin\Form\Forms;
use Grav\Plugin\Form\TwigExtension;
use Grav\Common\HTTP\Client;
use Monolog\Logger;
use ReCaptcha\ReCaptcha;
use ReCaptcha\RequestMethod\CurlPost;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;
use RocketTheme\Toolbox\File\JsonFile;
@ -88,7 +83,7 @@ class FormPlugin extends Plugin
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
'onTwigExtensions' => ['onTwigExtensions', 0],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0]
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
];
}
@ -97,7 +92,7 @@ class FormPlugin extends Plugin
*/
public function autoload()
{
return require __DIR__ . '/vendor/autoload.php';
return require __DIR__.'/vendor/autoload.php';
}
/**
@ -119,6 +114,10 @@ class FormPlugin extends Plugin
return $forms;
};
// Initialize the captcha manager
CaptchaManager::initialize();
if ($this->isAdmin()) {
$this->enable([
'onPageInitialized' => ['onPageInitialized', 0],
@ -154,7 +153,7 @@ class FormPlugin extends Plugin
}
/**
* @param Event $event
* @param Event $event
* @return void
*/
public function onGetPageTemplates(Event $event): void
@ -167,7 +166,7 @@ class FormPlugin extends Plugin
/**
* Process forms after page header processing, but before caching
*
* @param Event $event
* @param Event $event
* @return void
*/
public function onPageProcessed(Event $event): void
@ -252,7 +251,7 @@ class FormPlugin extends Plugin
if ($form instanceof Form) {
// Post the form
$isJson = $uri->extension() === 'json';
$task = (string)($uri->post('task') ?? $uri->param('task'));
$task = (string) ($uri->post('task') ?? $uri->param('task'));
if ($isJson) {
if ($task === 'store-state') {
@ -394,7 +393,19 @@ class FormPlugin extends Plugin
*/
public function onTwigExtensions(): void
{
$this->grav['twig']->twig->addExtension(new TwigExtension());
$twig = $this->grav['twig']->twig;
$twig->addExtension(new TwigExtension());
$twig->addFunction(new TwigFunction('captcha_template_exists', function ($template) use ($twig) {
return $twig->getLoader()->exists($template);
}));
// Add function to store basic captcha configuration in session
$twig->addFunction(new TwigFunction('store_basic_captcha_config', function ($fieldId, $config) {
$session = $this->grav['session'];
$sessionKey = "basic_captcha_config_{$fieldId}";
$session->{$sessionKey} = $config;
return true;
}));
}
/**
@ -404,16 +415,16 @@ class FormPlugin extends Plugin
*/
public function onTwigTemplatePaths(): void
{
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
$this->grav['twig']->twig_paths[] = __DIR__.'/templates';
}
/**
* Make form accessible from twig.
*
* @param Event|null $event
* @param Event|null $event
* @return void
*/
public function onTwigVariables(Event $event = null): void
public function onTwigVariables(?Event $event = null): void
{
if ($event && isset($event['page'])) {
$page = $event['page'];
@ -437,7 +448,7 @@ class FormPlugin extends Plugin
/**
* Handle form processing instructions.
*
* @param Event $event
* @param Event $event
* @return void
* @throws Exception
* @throws TransportExceptionInterface
@ -452,121 +463,25 @@ class FormPlugin extends Plugin
$this->process($form);
switch ($action) {
case 'captcha':
$captcha_config = $this->config->get('plugins.form.recaptcha');
$secret = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ?? $captcha_config['secret_key'];
/** @var Uri $uri */
$uri = $this->grav['uri'];
$action = $form->value('action');
$hostname = $uri->host();
$ip = Uri::ip();
$recaptcha = new ReCaptcha($secret);
if (extension_loaded('curl')) {
$recaptcha = new ReCaptcha($secret, new CurlPost());
}
// get captcha version
$captcha_version = $captcha_config['version'] ?? 2;
// Add version 3 specific options
if ($captcha_version == 3) {
$token = $form->value('token');
$resp = $recaptcha
->setExpectedHostname($hostname)
->setExpectedAction($action)
->setScoreThreshold(0.5)
->verify($token, $ip);
} else {
$token = $form->value('g-recaptcha-response', true);
$resp = $recaptcha
->setExpectedHostname($hostname)
->verify($token, $ip);
}
if (!$resp->isSuccess()) {
$errors = $resp->getErrorCodes();
$message = $this->grav['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
$fields = $form->value()->blueprints()->get('form/fields');
foreach ($fields as $field) {
$type = $field['type'] ?? 'text';
$field_message = $field['recaptcha_not_validated'] ?? null;
if ($type === 'captcha' && $field_message) {
$message = $field_message;
break;
}
}
$this->grav->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $message
]));
$this->grav['log']->warning('Form reCAPTCHA Errors: [' . $uri->route() . '] ' . json_encode($errors));
$event->stopPropagation();
return;
}
break;
case 'basic-captcha':
$captcha = new BasicCaptcha();
$captcha_value = trim($form->value('basic-captcha'));
if (!$captcha->validateCaptcha($captcha_value)) {
$message = $params['message'] ?? $this->grav['language']->translate('PLUGIN_FORM.ERROR_BASIC_CAPTCHA');
$form->setData('basic-captcha', '');
$this->grav->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $message
]));
$event->stopPropagation();
return;
}
break;
case 'turnstile':
/** @var Uri $uri */
$uri = $this->grav['uri'];
case 'captcha':
// Convert boolean params to array if needed
$captcha_params = is_array($params) ? $params : [];
$turnstile_config = $this->config->get('plugins.form.turnstile');
$secret = $turnstile_config['secret_key'] ?? null;
$token = $form->getValue('cf-turnstile-response') ?? null;
$ip = Uri::ip();
$client = Client::getClient();
$response = $client->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $secret,
'response' => $token,
'remoteip' => $ip
]
]);
$content = $response->toArray();
if (!$content['success']) {
$message = $params['message'] ?? $this->grav['language']->translate('PLUGIN_FORM.ERROR_BASIC_CAPTCHA');
$this->grav->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $message
]));
$this->grav['log']->warning('Form Turnstile invalid: [' . $uri->route() . '] ' . json_encode($content));
// Use the captcha manager to validate
$validated = CaptchaManager::validateCaptcha($form, $captcha_params);
if (!$validated) {
$event->stopPropagation();
return;
}
break;
case 'timestamp':
$label = $params['label'] ?? 'Timestamp';
$format = $params['format'] ?? 'Y-m-d H:i:s';
$blueprint = $form->value()->blueprints();
$blueprint->set('form/fields/timestamp', ['name' => 'timestamp', 'label' => $label, 'type' => 'hidden']);
$blueprint->set('form/fields/timestamp',
['name' => 'timestamp', 'label' => $label, 'type' => 'hidden']);
$now = new DateTime('now');
$date_string = $now->format($format);
$form->setFields($blueprint->fields());
@ -593,7 +508,7 @@ class FormPlugin extends Plugin
break;
case 'redirect':
$this->grav['session']->setFlashObject('form', $form);
$url = ((string)$params);
$url = ((string) $params);
$vars = array(
'form' => $form
);
@ -616,11 +531,11 @@ class FormPlugin extends Plugin
}
break;
case 'display':
$route = (string)$params;
$route = (string) $params;
if (!$route || $route[0] !== '/') {
/** @var Uri $uri */
$uri = $this->grav['uri'];
$route = rtrim($uri->route(), '/') . '/' . ($route ?: '');
$route = rtrim($uri->route(), '/').'/'.($route ?: '');
}
/** @var Twig $twig */
@ -640,7 +555,7 @@ class FormPlugin extends Plugin
break;
case 'remember':
foreach ($params as $remember_field) {
$field_cookie = 'forms-' . $form['name'] . '-' . $remember_field;
$field_cookie = 'forms-'.$form['name'].'-'.$remember_field;
setcookie($field_cookie, $form->value($remember_field), time() + 60 * 60 * 24 * 60);
}
break;
@ -652,9 +567,9 @@ class FormPlugin extends Plugin
case 'save':
$prefix = $params['fileprefix'] ?? '';
$format = $params['dateformat'] ?? 'Ymd-His-u';
$raw_format = (bool)($params['dateraw'] ?? false);
$raw_format = (bool) ($params['dateraw'] ?? false);
$postfix = $params['filepostfix'] ?? '';
$ext = !empty($params['extension']) ? '.' . trim($params['extension'], '.') : '.txt';
$ext = !empty($params['extension']) ? '.'.trim($params['extension'], '.') : '.txt';
$filename = $params['filename'] ?? '';
$folder = !empty($params['folder']) ? $params['folder'] : $form->getName();
$operation = $params['operation'] ?? 'create';
@ -664,7 +579,7 @@ class FormPlugin extends Plugin
throw new RuntimeException('Form save: \'operation: add\' is only supported with a static filename');
}
$filename = $prefix . $this->udate($format, $raw_format) . $postfix . $ext;
$filename = $prefix.$this->udate($format, $raw_format).$postfix.$ext;
}
// Handle bad filenames.
@ -683,8 +598,8 @@ class FormPlugin extends Plugin
$locator = $this->grav['locator'];
$path = $locator->findResource('user-data://', true);
$dir = $path . DS . $folder;
$fullFileName = $dir . DS . $filename;
$dir = $path.DS.$folder;
$fullFileName = $dir.DS.$filename;
if (!empty($params['raw']) || !empty($params['template'])) {
// Save data as it comes from the form.
@ -780,7 +695,7 @@ class FormPlugin extends Plugin
/**
* Custom field logic can go in here
*
* @param Event $event
* @param Event $event
* @return void
*/
public function onFormValidationProcessed(Event $event): void
@ -796,7 +711,7 @@ class FormPlugin extends Plugin
/**
* Handle form validation error
*
* @param Event $event An event object
* @param Event $event An event object
* @return void
* @throws Exception
*/
@ -832,7 +747,7 @@ class FormPlugin extends Plugin
/**
* Add a form definition to the forms plugin
*
* @param PageInterface $page
* @param PageInterface $page
* @return void
*/
public function addFormDefinition(PageInterface $page, string $name, array $form): void
@ -850,8 +765,8 @@ class FormPlugin extends Plugin
/**
* Add a form to the forms plugin
*
* @param string|null $route
* @param FormInterface|null $form
* @param string|null $route
* @param FormInterface|null $form
* @return void
*/
public function addForm(?string $route, ?FormInterface $form): void
@ -874,7 +789,7 @@ class FormPlugin extends Plugin
/**
* function to get a specific form
*
* @param string|array|null $data Optional form name or ['name' => $name, 'route' => $route]
* @param string|array|null $data Optional form name or ['name' => $name, 'route' => $route]
* @return FormInterface|null
*/
public function getForm($data = null): ?FormInterface
@ -885,8 +800,8 @@ class FormPlugin extends Plugin
// Handle parameters.
if (is_array($data)) {
$name = (string)($data['name'] ?? '');
$route = (string)($data['route'] ?? '');
$name = (string) ($data['name'] ?? '');
$route = (string) ($data['route'] ?? '');
} elseif (is_string($data)) {
$name = $data;
$route = '';
@ -943,7 +858,8 @@ class FormPlugin extends Plugin
if (null === $form) {
// First check if we requested a specific form which didn't exist.
if ($route_provided || $unnamed) {
$this->grav['debugger']->addMessage(sprintf('Form %s not found in page %s', $name ?? 'unnamed', $route), 'warning');
$this->grav['debugger']->addMessage(sprintf('Form %s not found in page %s', $name ?? 'unnamed', $route),
'warning');
return null;
}
@ -957,7 +873,8 @@ class FormPlugin extends Plugin
// Check for naming conflicts.
if (count($forms) > 1) {
$this->grav['debugger']->addMessage(sprintf('Fetching form by its name, but there are multiple pages with the same form name %s', $name), 'warning');
$this->grav['debugger']->addMessage(sprintf('Fetching form by its name, but there are multiple pages with the same form name %s',
$name), 'warning');
}
[$route, $name, $form] = $first;
@ -969,7 +886,8 @@ class FormPlugin extends Plugin
if (is_array($form)) {
// Form was cached as an array, try to create the object.
if (null === $page) {
$this->grav['debugger']->addMessage(sprintf('Form %s cannot be created as page %s does not exist', $name, $route), 'warning');
$this->grav['debugger']->addMessage(sprintf('Form %s cannot be created as page %s does not exist',
$name, $route), 'warning');
return null;
}
@ -1071,7 +989,7 @@ class FormPlugin extends Plugin
*
* - fillWithCurrentDateTime
*
* @param FormInterface $form
* @param FormInterface $form
* @return void
*/
protected function process($form)
@ -1098,7 +1016,7 @@ class FormPlugin extends Plugin
/**
* Return all forms matching the given name.
*
* @param string $name
* @param string $name
* @return array
*/
protected function findFormByName(string $name): array
@ -1127,7 +1045,7 @@ class FormPlugin extends Plugin
{
/** @var Uri $uri */
$uri = $this->grav['uri'];
$status = (bool)$uri->post('form-nonce');
$status = (bool) $uri->post('form-nonce');
if ($status && $form = $this->form()) {
// Make sure form is something we recognize.
@ -1135,7 +1053,7 @@ class FormPlugin extends Plugin
return false;
}
if (isset($form->xhr_submit) && $form->xhr_submit) {
if (isset($form->xhr_submit) && $form->xhr_submit && $this->isFormXhrRequest()) {
$form->set('template', $form->template ?? 'form-xhr');
}
@ -1145,7 +1063,7 @@ class FormPlugin extends Plugin
}
if (isset($form->refresh_prevention)) {
$refresh_prevention = (bool)$form->refresh_prevention;
$refresh_prevention = (bool) $form->refresh_prevention;
} else {
$refresh_prevention = $this->config->get('plugins.form.refresh_prevention', false);
}
@ -1173,10 +1091,10 @@ class FormPlugin extends Plugin
/**
* Get the current form, should already be processed but can get it directly from the page if necessary
*
* @param PageInterface|null $page
* @param PageInterface|null $page
* @return FormInterface|null
*/
protected function form(PageInterface $page = null)
protected function form(?PageInterface $page = null)
{
/** @var Forms $forms */
$forms = $this->grav['forms'];
@ -1223,12 +1141,12 @@ class FormPlugin extends Plugin
}
/**
* @param PageInterface $page
* @param string|null $name
* @param array|null $form
* @param PageInterface $page
* @param string|null $name
* @param array|null $form
* @return FormInterface|null
*/
protected function createForm(PageInterface $page, string $name = null, array $form = null): ?FormInterface
protected function createForm(PageInterface $page, ?string $name = null, ?array $form = null): ?FormInterface
{
/** @var Forms $forms */
$forms = $this->grav['forms'];
@ -1250,7 +1168,8 @@ class FormPlugin extends Plugin
$forms = $cache->fetch($this->getFormCacheId());
} catch (Exception $e) {
$this->grav['debugger']->addMessage(sprintf('Unserializing cached forms failed: %s', $e->getMessage()), 'error');
$this->grav['debugger']->addMessage(sprintf('Unserializing cached forms failed: %s', $e->getMessage()),
'error');
$forms = null;
}
@ -1262,7 +1181,8 @@ class FormPlugin extends Plugin
if ($forms) {
$this->forms = Utils::arrayMergeRecursiveUnique($this->forms, $forms);
if ($this->config()['debug']) {
$this->grav['log']->debug(sprintf("<<<< Loaded cached forms: %s\n%s", $this->getFormCacheId(), $this->arrayToString($this->forms)));
$this->grav['log']->debug(sprintf("<<<< Loaded cached forms: %s\n%s", $this->getFormCacheId(),
$this->arrayToString($this->forms)));
}
}
@ -1286,7 +1206,8 @@ class FormPlugin extends Plugin
$cache->save($cache_id, $this->forms);
if ($this->config()['debug']) {
$this->grav['log']->debug(sprintf(">>>> Saved cached forms: %s\n%s", $this->getFormCacheId(), $this->arrayToString($this->forms)));
$this->grav['log']->debug(sprintf(">>>> Saved cached forms: %s\n%s", $this->getFormCacheId(),
$this->arrayToString($this->forms)));
}
}
@ -1300,17 +1221,17 @@ class FormPlugin extends Plugin
/** @var \Grav\Common\Cache $cache */
$cache = $this->grav['cache'];
/** @var Pages $pages */
$pages= $this->grav['pages'];
$pages = $this->grav['pages'];
// $cache_id = $cache->getKey() . '-form-plugin';
$cache_id = $pages->getPagesCacheId() . '-form-plugin';
$cache_id = $pages->getPagesCacheId().'-form-plugin';
return $cache_id;
}
/**
* Create unix timestamp for storing the data into the filesystem.
*
* @param string $format
* @param bool $raw
* @param string $format
* @param bool $raw
* @return string
*/
protected function udate($format = 'u', $raw = false)
@ -1326,10 +1247,22 @@ class FormPlugin extends Plugin
return date(preg_replace('`(?<!\\\\)u`', sprintf('%06d', $milliseconds), $format), $timestamp);
}
protected function processBasicCaptchaImage(Uri $uri)
protected function processBasicCaptchaImage(Uri $uri): void
{
if ($uri->path() === '/forms-basic-captcha-image.jpg') {
$captcha = new BasicCaptcha();
// Get field ID from query parameter
$fieldId = $_GET['field'] ?? null;
$fieldConfig = null;
// Retrieve field-specific configuration from session if available
if ($fieldId) {
$session = $this->grav['session'];
$sessionKey = "basic_captcha_config_{$fieldId}";
$fieldConfig = $session->{$sessionKey} ?? null;
}
// Create captcha with field-specific or global config
$captcha = new BasicCaptcha($fieldConfig);
$code = $captcha->getCaptchaCode();
$image = $captcha->createCaptchaImage($code);
$captcha->renderCaptchaImage($image);
@ -1337,12 +1270,14 @@ class FormPlugin extends Plugin
}
}
protected function arrayToString($array, $level = 2) {
protected function arrayToString($array, $level = 2)
{
$result = $this->limitArrayLevels($array, $level);
return json_encode($result, JSON_UNESCAPED_SLASHES);
}
protected function limitArrayLevels($array, $levelsToKeep, $currentLevel = 0) {
protected function limitArrayLevels($array, $levelsToKeep, $currentLevel = 0)
{
if ($currentLevel >= $levelsToKeep) {
return '-';
}
@ -1357,4 +1292,21 @@ class FormPlugin extends Plugin
return $result;
}
protected function isFormXhrRequest(): bool
{
if (!$this->grav['request']) {
return false;
}
// Check 1: Our custom header (most reliable for our purpose)
$isCustomXhr = $this->grav['request']->getHeaderLine('X-Grav-Form-XHR') === 'true';
// Check 2: Standard X-Requested-With (often added by libraries)
$isStdXhr = $this->grav['request']->getHeaderLine('X-Requested-With') === 'XMLHttpRequest';
// Require our custom header for the specific partial rendering logic.
// You could use || $isStdXhr if you want to be more lenient, but $isCustomXhr is stricter.
return $isCustomXhr && $isStdXhr;
}
}

View file

@ -26,18 +26,29 @@ turnstile:
secret_key:
basic_captcha:
type: characters # options: [characters | math]
type: math # Options: dotcount, position, math, characters
debug: false # Enable debug logging for troubleshooting
# Image settings
image:
width: 135 # Image width (default: 135 for classic size)
height: 40 # Image height (default: 40 for classic size)
bg: '#ffffff' # Background color
# Character captcha settings (used for the 'characters' type)
chars:
length: 6 # number of chars to output
font: zxx-noise.ttf # options: [zxx-noise.ttf | zxx-camo.ttf | zxx-xed.ttf | zxx-sans.ttf]
bg: '#cccccc' # 6-char hex color
text: '#333333' # 6-char hex color
size: 24 # font size in px
start_x: 5 # start position in x direction in px
start_y: 30 # start position in y direction in px
box_width: 135 # box width in px
box_height: 40 # box height in px
length: 6 # Number of characters to display
font: zxx-xed.ttf # Font file in the plugin's fonts directory
size: 24 # Font size
box_width: 200 # Image width
box_height: 70 # Image height
start_x: 10 # Starting X position for text
start_y: 40 # Starting Y position for text
bg: '#ffffff' # Background color
text: '#000000' # Text color
# Math puzzle settings (used for the 'math' type)
math:
min: 1 # smallest digit
max: 12 # largest digit
operators: ['+','-','*'] # operators that can be used in math
min: 1 # Minimum number value
max: 12 # Maximum number value
operators: ['+','-','*'] # Available operators

File diff suppressed because it is too large Load diff

8861
plugins/form/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,11 @@
"dependencies": {
"dropzone": "getgrav/dropzone#master",
"exif-js": "^2.3.0",
"filepond": "^4.32.7",
"filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-preview": "^4.6.12",
"filepond-plugin-image-resize": "^2.0.10",
"sortablejs": "^1.10.2"
},
"devDependencies": {
@ -22,22 +27,22 @@
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"babel-polyfill": "^6.26.0",
"css-loader": "^3.4.2",
"css-loader": "^7.1.2",
"eslint": "^6.8.0",
"eslint-loader": "^3.0.4",
"exports-loader": "^0.7.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^7.0.1",
"gulp": "^5.0.0",
"gulp-autoprefixer": "^9.0.0",
"gulp-clean-css": "^4.3.0",
"gulp-csscomb": "^3.1.0",
"gulp-csscomb": "^0.1.0",
"gulp-rename": "^2.0.0",
"gulp-webpack": "^1.5.0",
"gulp-webpack": "^0.0.1",
"immutable": "^4.0.0-rc.12",
"imports-loader": "^0.8.0",
"json-loader": "^0.5.7",
"style-loader": "^1.1.3",
"uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
}

View file

@ -58,6 +58,32 @@ $form-active-color: #000;
z-index: 2;
}
.dz-image img {
margin: 0;
}
.dz-remove {
font-size: 16px;
position: absolute;
top: 3px;
right: 3px;
display: inline-flex;
height: 20px;
width: 20px;
background-color: red;
justify-content: center;
align-items: center;
color: white;
font-weight: bold;
border-radius: 50%;
cursor: pointer;
z-index: 20;
&:hover {
background-color: darkred;
text-decoration: none;
}
}
.dz-error-message {
min-width: 140px;
width: auto;
@ -72,7 +98,13 @@ $form-active-color: #000;
}
}
//Filepond
.filepond--root.form-input {
min-height: 7rem;
height: auto;
overflow:hidden;
border: 0;
}
// New JS powered tabs
.form-tabs {

View file

@ -1,75 +1,81 @@
{% macro render_field(form, fields, scope) %}
{% import _self as self %}
{% import _self as self %}
{% for index, field in fields %}
{%- set show_field = attribute(field, "input@") ?? field.store ?? true %}
{% if field.fields %}
{%- set new_scope = field.nest_id ? scope ~ field.name ~ '.' : scope -%}
{{- self.render_field(form, field.fields, new_scope) }}
{% else %}
{% if show_field %}
{%- set value = form.value(scope ~ (field.name ?? index)) -%}
{% if value %}
{% block field %}
<div>
{% block field_label %}
<strong>{{ field.label|t|e }}</strong>:
{% endblock %}
{% for index, field in fields %}
{%- set show_field = attribute(field, "input@") ?? field.store ?? true %}
{% if field.fields %}
{%- set new_scope = field.nest_id ? scope ~ field.name ~ '.' : scope -%}
{{- self.render_field(form, field.fields, new_scope) }}
{% else %}
{% if show_field %}
{%- set value = form.value(scope ~ (field.name ?? index)) -%}
{% if value %}
{% block field %}
<div>
{% block field_label %}
<strong>
{%- if field.markdown -%}
{{ field.data_label ? field.data_label|t|e|markdown(false) : field.label|t|emarkdown(false) }}
{%- else -%}
{{ field.data_label ? field.data_label|t|e : field.label|t|e }}
{%- endif -%}
</strong>:
{% endblock %}
{% block field_value %}
{% if field.type == 'checkboxes' %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key,value in form.value(scope ~ field.name) %}
{% set index = (use_keys ? key : value) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% elseif field.type == 'radio' %}
{% set value = form.value(scope ~ field.name) %}
{{ field.options[value]|t|e }}
{% elseif field.type == 'checkbox' %}
{{ (form.value(scope ~ field.name) == 1) ? "GRAV.YES"|t|e : "GRAV.NO"|t|e }}
{% elseif field.type == 'select' %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key, val in value %}
{% set index = (use_keys ? key : val) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% else %}
{{ field.options[value]|t|e }}
{% endif %}
{% else %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% for val in value %}
{% if val is iterable %}
<ul>
{% for v in val %}
<li>{{ string(v)|e }}</li>
{% endfor %}
</ul>
{% else %}
<li>{{ string(val)|e }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
{{ string(value)|e|nl2br }}
{% endif %}
{% endif %}
{% endblock %}
</div>
{% endblock %}
{% block field_value %}
{% if field.type == 'checkboxes' %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key,value in form.value(scope ~ field.name) %}
{% set index = (use_keys ? key : value) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% elseif field.type == 'radio' %}
{% set value = form.value(scope ~ field.name) %}
{{ field.options[value]|t|e }}
{% elseif field.type == 'checkbox' %}
{{ (form.value(scope ~ field.name) == 1) ? "GRAV.YES"|t|e : "GRAV.NO"|t|e }}
{% elseif field.type == 'select' %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key, val in value %}
{% set index = (use_keys ? key : val) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% else %}
{{ field.options[value]|t|e }}
{% endif %}
{% else %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% for val in value %}
{% if val is iterable %}
<ul>
{% for v in val %}
<li>{{ string(v)|e }}</li>
{% endfor %}
</ul>
{% else %}
<li>{{ string(val)|e }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
{{ string(value)|e|nl2br }}
{% endif %}
{% endif %}
{% endif %}
{% endblock %}
</div>
{% endblock %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{% import _self as macro %}

View file

@ -9,7 +9,7 @@
{%- if show_field %}
{%- set value = form.value(scope ~ (field.name ?? index)) -%}
{%- if value -%}
{{- field.label|t|e }}: {{ string(value is iterable ? value|json_encode : value) ~ "\n" }}
{{- field.data_label ? field.data_label|t|e : field.label|t|e }}: {{ string(value is iterable ? value|json_encode : value) ~ "\n" }}
{%- endif -%}
{%- endif %}
{%- endif %}

View file

@ -3,6 +3,7 @@
{% set layout = layout ?? form.layout ?? 'default' %}
{% set field_layout = field_layout ?? layout %}
<div id="{{ form.id }}-wrapper" class="form-wrapper">
{# Keep here for Backwards Compatibility #}
{% include 'partials/form-messages.html.twig' %}
@ -103,8 +104,9 @@
name="{{ form.name }}"
action="{{ action }}"
method="{{ method }}"{{ multipart|raw }}
{% if form.id %}id="{{ form.id }}"{% endif %}
id="{{ form.id|default(form.name|hyphenize) }}"
{% if form.novalidate %}novalidate{% endif %}
{% if form.xhr_submit %}data-xhr-enabled="true"{% endif %}
{% if form.keep_alive %}data-grav-keepalive="true"{% endif %}
{% if form.attributes is defined %}
{% for key,attribute in form.attributes %}
@ -201,9 +203,9 @@
{% endembed %}
{% if config.forms.dropzone.enabled %}
<div id="dropzone-template" style="display:none;">
{% include 'forms/dropzone/template.html.twig' %}
</div>
{% endif %}
</div>

View file

@ -3,20 +3,27 @@
{% extends "forms/field.html.twig" %}
{% block prepend %}
<div class="form-input-addon form-input-prepend">
<img id="basic-captcha-reload" src="{{ url('/forms-basic-captcha-image.jpg') }}" alt="human test" />
<button id="reload-captcha"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="M14.74 22.39c4.68-1.24 8-5.49 8-10.4 0-5.95-4.79-10.75-10.75-10.75 -3.11 0-5.78 1.11-7.99 2.95 -.77.64-1.43 1.32-1.98 2.01 -.34.41-.57.75-.69.95 -.22.35-.1.81.25 1.02 .35.21.81.09 1.02-.26 .08-.15.27-.43.56-.79 .49-.62 1.08-1.23 1.76-1.81C6.87 3.67 9.21 2.7 11.94 2.7c5.13 0 9.25 4.12 9.25 9.25 0 4.22-2.86 7.88-6.9 8.94 -.41.1-.64.51-.54.91 .1.4.51.63.91.53Zm-12-14.84V2.99c-.001-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75v4.56c0 .41.33.75.75.75 .41 0 .75-.34.75-.75Zm-.75.75H4h2.43c.41 0 .75-.34.75-.75 0-.42-.34-.75-.75-.75H4 1.99c-.42 0-.75.33-.75.75 0 .41.33.75.75.75Z"/><path d="M1.25 12c0 1.09.16 2.16.48 3.18 .12.39.54.61.93.49 .39-.13.61-.55.49-.94 -.28-.89-.42-1.81-.42-2.75 0-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75Zm1.93 6.15c.61.88 1.36 1.67 2.22 2.33 .32.25.79.19 1.05-.14 .25-.33.19-.8-.14-1.06 -.74-.58-1.38-1.25-1.92-2.02 -.24-.34-.71-.43-1.05-.19 -.34.23-.43.7-.19 1.04Zm5.02 3.91c1 .37 2.06.6 3.15.66 .41.02.76-.3.79-.71 .02-.42-.3-.77-.71-.8 -.94-.06-1.85-.25-2.72-.58 -.39-.15-.83.04-.97.43 -.15.38.04.82.43.96Z"/></g></svg></button>
<script>
function stripQueryString(url) {
return url.split("?")[0].split("#")[0];
}
document.getElementById("reload-captcha").onclick = function(event) {
event.preventDefault();
const src = stripQueryString(document.getElementById("basic-captcha-reload").src);
document.getElementById("basic-captcha-reload").src = src + `?v=${new Date().getTime()}`;
}
</script>
{% set field_id = field.name|default('default') %}
{% set config_hash = (form.id ~ '_basic_captcha_' ~ field_id)|md5 %}
{% set image_url = url('/forms-basic-captcha-image.jpg') ~ '?field=' ~ config_hash %}
{# Store field configuration in session for image generation #}
{% set global_config = grav.config.get('plugins.form.basic_captcha', {}) %}
{% set merged_config = global_config|merge(field) %}
{% do store_basic_captcha_config(config_hash, merged_config) %}
<div class="form-input-addon form-input-prepend"
data-captcha-provider="basic-captcha"
data-field-id="{{ config_hash }}">
<img id="basic-captcha-reload-{{ form.id }}"
src="{{ image_url }}"
alt="human test"
data-base-url="{{ url('/forms-basic-captcha-image.jpg') }}"
data-field-id="{{ config_hash }}" />
<button type="button" id="reload-captcha-{{ form.id }}" class="reload-captcha-button"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="M14.74 22.39c4.68-1.24 8-5.49 8-10.4 0-5.95-4.79-10.75-10.75-10.75 -3.11 0-5.78 1.11-7.99 2.95 -.77.64-1.43 1.32-1.98 2.01 -.34.41-.57.75-.69.95 -.22.35-.1.81.25 1.02 .35.21.81.09 1.02-.26 .08-.15.27-.43.56-.79 .49-.62 1.08-1.23 1.76-1.81C6.87 3.67 9.21 2.7 11.94 2.7c5.13 0 9.25 4.12 9.25 9.25 0 4.22-2.86 7.88-6.9 8.94 -.41.1-.64.51-.54.91 .1.4.51.63.91.53Zm-12-14.84V2.99c-.001-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75v4.56c0 .41.33.75.75.75 .41 0 .75-.34.75-.75Zm-.75.75H4h2.43c.41 0 .75-.34.75-.75 0-.42-.34-.75-.75-.75H4 1.99c-.42 0-.75.33-.75.75 0 .41.33.75.75.75Z"/><path d="M1.25 12c0 1.09.16 2.16.48 3.18 .12.39.54.61.93.49 .39-.13.61-.55.49-.94 -.28-.89-.42-1.81-.42-2.75 0-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75Zm1.93 6.15c.61.88 1.36 1.67 2.22 2.33 .32.25.79.19 1.05-.14 .25-.33.19-.8-.14-1.06 -.74-.58-1.38-1.25-1.92-2.02 -.24-.34-.71-.43-1.05-.19 -.34.23-.43.7-.19 1.04Zm5.02 3.91c1 .37 2.06.6 3.15.66 .41.02.76-.3.79-.71 .02-.42-.30-.77-.71-.80 -.94-.06-1.85-.25-2.72-.58 -.39-.15-.83.04-.97.43 -.15.38.04.82.43.96Z"/></g></svg></button>
</div>
{% do assets.addJs('plugin://form/assets/captcha/basic-captcha-refresh.js') %}
{% endblock %}
{% block input_attributes %}

View file

@ -1,100 +1,18 @@
{% extends "forms/field.html.twig" %}
{% set config = grav.config %}
{% set site_key = field.recaptcha_site_key and field.recaptcha_site_key != 'ENTER_YOUR_CAPTCHA_SITE_KEY' ? field.recaptcha_site_key : config.plugins.form.recaptcha.site_key %}
{% set action = (page.route|trim('/') ~ '-' ~ form.name)|underscorize %}
{% set formName = form.name|underscorize %}
{% set theme = config.plugins.form.recaptcha.theme ?? 'light' %}
{% block field %}
{# This main captcha field serves as a router to the appropriate provider template #}
{% set provider = field.provider %}
{% block label %}{% endblock %}
{% if provider is not defined or provider == null %}
{% set provider = 'recaptcha' %}
{% endif %}
{% block input %}
{% if not site_key %}
<script type="application/javascript">console && console.error('site_key was not defined for form "{{ form.name }}" (Grav Form Plugin)')</script>
{% elseif config.plugins.form.recaptcha.version == 3 %}
{% do assets.addJs('https://www.google.com/recaptcha/api.js?render='~site_key~'&theme=' ~ theme) %}
{#<script src='https://www.google.com/recaptcha/api.js?render={{ site_key }}&theme={{ theme }}'></script>#}
<script type="application/javascript">
window.gRecaptchaInstances = window.gRecaptchaInstances || {};
window.gRecaptchaInstances['{{ form.id }}'] = {
element: document.querySelector('form#{{ form.id }}'),
submit: function (event) {
event.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ site_key }}', {action: '{{ action }}'}).then(function (token) {
var tokenElement = document.createElement('input');
tokenElement.setAttribute('type', 'hidden');
tokenElement.setAttribute('name', 'data[token]');
tokenElement.setAttribute('value', token);
{% set template = 'forms/fields/' ~ provider ~ '/' ~ provider ~ '.html.twig' %}
var actionElement = document.createElement('input');
actionElement.setAttribute('type', 'hidden');
actionElement.setAttribute('name', 'data[action]');
actionElement.setAttribute('value', '{{ action }}');
const form = window.gRecaptchaInstances['{{ form.id }}'].element;
const submit = window.gRecaptchaInstances['{{ form.id }}'].submit;
form.insertBefore(tokenElement, form.firstChild);
form.insertBefore(actionElement, form.firstChild);
form.removeEventListener('submit', submit);
form.submit();
});
});
}
};
window.gRecaptchaInstances['{{ form.id }}'].element.addEventListener('submit', window.gRecaptchaInstances['{{ form.id }}'].submit);
</script>
{% elseif config.plugins.form.recaptcha.version == '2-invisible' %}
<script type="application/javascript">
function captchaOnloadCallback_{{ formName }}() {
var form = document.querySelector('form#{{ form.id }}');
var submits = form.querySelectorAll('[type="submit"]') || [];
submits.forEach(function(submit) {
submit.addEventListener('click', function(event) {
event.preventDefault();
var captchaElement = form.querySelector('#g-recaptcha-{{ formName }}');
if (captchaElement) {
captchaElement.remove();
}
captchaElement = document.createElement('div');
captchaElement.setAttribute('id', 'g-recaptcha-{{ formName }}');
form.appendChild(captchaElement);
var widgetReference = grecaptcha.render('g-recaptcha-{{ formName }}', {
sitekey: '{{ site_key }}', size: 'invisible',
callback: function(/* token */) {
form.submit();
}
});
grecaptcha.execute(widgetReference);
});
});
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=captchaOnloadCallback_{{ formName }}&hl={{ grav.language.language }}&theme={{ theme }}"
async defer></script>
{% else %}
<script type="application/javascript">
var captchaOnloadCallback_{{ formName }} = function captchaOnloadCallback_{{ formName }}() {
grecaptcha.render('g-recaptcha-{{ formName }}', {
'sitekey': "{{ site_key }}",
'callback': captchaValidatedCallback_{{ formName }},
'expired-callback': captchaExpiredCallback_{{ formName }}
});
};
var captchaValidatedCallback_{{ formName }} = function captchaValidatedCallback_{{ formName }}() {};
var captchaExpiredCallback_{{ formName }} = function captchaExpiredCallback_{{ formName }}() {
grecaptcha.reset();
};
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=captchaOnloadCallback_{{ formName }}&render=explicit&hl={{ grav.language.language }}&theme={{ theme }} "
async defer></script>
<div class="g-recaptcha" id="g-recaptcha-{{ formName }}" data-theme="{{ theme }}"></div>
{% endif %}
{% endblock %}
{% if captcha_template_exists(template) %}
{% include template with {'field': field} %}
{% else %}
<div class="form-error" style="color:#c00000;">ERROR - unknown captcha provider: <strong>{{ provider }}</strong></div>
{% endif %}
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "forms/field.html.twig" %}
{% macro bytesToSize(bytes) -%}
{% spaceless %}
{% apply spaceless %}
{% set kilobyte = 1024 %}
{% set megabyte = kilobyte * 1024 %}
{% set gigabyte = megabyte * 1024 %}
@ -18,7 +18,7 @@
{% else %}
{{ (bytes / terabyte)|number_format(2, '.') ~ ' TB' }}
{% endif %}
{% endspaceless %}
{% endapply %}
{%- endmacro %}
{% macro preview(path, value, global) %}
@ -69,6 +69,7 @@
<div class="{{ form_field_wrapper_classes ?: 'form-input-wrapper' }} {{ field.classes }} dropzone files-upload form-input-file {{ field.size }}"
data-grav-file-settings="{{ settings|json_encode|e('html_attr') }}"
data-dropzone-options="{{ dropzoneSettings|json_encode|e('html_attr') }}"
data-file-field-name="{{ field.name }}"
{% if file_url_add %}data-file-url-add="{{ file_url_add|e('html_attr') }}"{% endif %}
{% if file_url_remove %}data-file-url-remove="{{ file_url_remove|e('html_attr') }}"{% endif %}>
{% block file_extras %}{% endblock %}
@ -98,12 +99,16 @@
</div>
{% endif %}
{% if form.xhr_submit %}
{% do assets.addJs('plugin://form/assets/dropzone-reinit.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 90 }) %}
{% endif %}
{% if grav.browser.browser == 'msie' and grav.browser.version < 12 %}
{% do assets.addJs('plugin://form/assets/object.assign.polyfill.js') %}
{% endif %}
{% do assets.addJs('jquery', 101) %}
{% do assets.addJs('plugin://form/assets/form.vendor.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% do assets.addJs('plugin://form/assets/form.min.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% do assets.addJs('plugin://form/assets/form.vendor.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 100 }) %}
{% do assets.addJs('plugin://form/assets/form.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 99 }) %}
{% do assets.addCss('plugin://form/assets/dropzone.min.css', { 'group': 'form'}) %}
{{ assets.css('form')|raw }}
{% do assets.addInlineJs("
@ -128,4 +133,4 @@
}
});
", {'group': 'bottom', 'position': 'before'}) %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,128 @@
{% extends "forms/field.html.twig" %}
{% set defaults = config.plugins.form %}
{% set files = defaults.files|merge(field|default([])) %}
{% set limit = not field.multiple ? 1 : files.limit %}
{% block input %}
{% set page_can_upload = exists or (type == 'page' and not exists and not (field.destination starts with '@self' or field.destination starts with 'self@')) %}
{% set max_filesize = (field.filesize > form_max_filesize or field.filesize == 0) ? form_max_filesize : field.filesize %}
{% block prepend %}{% endblock %}
{% set settings = {name: field.name, paramName: (scope ~ field.name)|fieldName ~ (files.multiple ? '[]' : ''), limit: limit, filesize: max_filesize, accept: files.accept, resolution: files.resolution, resizeWidth: field.filepond.resize_width, resizeHeight: field.filepond.resize_height, resizeQuality: field.filepond.resize_quality } %}
{% set filepond_settings = field.filepond|default({}) %}
{% set file_url_add = form.getFileUploadAjaxRoute().getUri() %}
{% set file_url_remove = form.getFileDeleteAjaxRoute(null, null).getUri() %}
<div class="{{ form_field_wrapper_classes ?: 'form-input-wrapper' }} {{ field.classes }} filepond-root form-input-file {{ field.size }}"
data-grav-file-settings="{{ settings|json_encode|e('html_attr') }}"
data-filepond-options="{{ filepond_settings|json_encode|e('html_attr') }}"
data-file-field-name="{{ field.name }}"
{% if file_url_add %}data-file-url-add="{{ file_url_add|e('html_attr') }}"{% endif %}
{% if file_url_remove %}data-file-url-remove="{{ file_url_remove|e('html_attr') }}"{% endif %}>
{% block file_extras %}{% endblock %}
<input
{# required attribute structures #}
{% block input_attributes %}
type="file"
{% if files.multiple %}multiple="multiple"{% endif %}
{% if files.accept %}accept="{{ files.accept|join(',') }}"{% endif %}
{% if field.disabled %}disabled="disabled"{% endif %}
{% if field.random_name %}random="true"{% endif %}
{% if required %}required="required"{% endif %}
{{ parent() }}
{% endblock %}
/>
{% for path, file in value %}
<div class="hidden" data-file="{{ file|merge({remove: file.remove|default(''), path: file.path|default('')})|json_encode|e('html_attr') }}"></div>
{% endfor %}
{% include 'forms/fields/hidden/hidden.html.twig' with {field: {name: '_json.' ~ field.name}, value: (value ?? [])|json_encode } %}
</div>
{% if inline_errors and errors %}
<div class="{{ form_field_inline_error_classes }}">
<p class="form-message"><i class="fa fa-exclamation-circle"></i> {{ errors|first|raw }}</p>
</div>
{% endif %}
{% if grav.browser.browser == 'msie' and grav.browser.version < 12 %}
{% do assets.addJs('plugin://form/assets/object.assign.polyfill.js') %}
{% endif %}
{% do assets.addJs('jquery', 101) %}
{# FilePond core and plugins #}
{% do assets.addJs('plugin://form/assets/filepond/filepond.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 98 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-file-validate-size.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-file-validate-type.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-preview.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-resize.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-transform.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{# FilePond CSS #}
{% do assets.addCss('plugin://form/assets/filepond/filepond.min.css') %}
{% do assets.addCss('plugin://form/assets/filepond/filepond-plugin-image-preview.min.css') %}
{# Custom handlers - note: load this AFTER the libraries #}
{% do assets.addJs('plugin://form/assets/filepond-handler.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 96 }) %}
{# {% if form.xhr_submit %}#}
{# {% do assets.addJs('plugin://form/assets/filepond-reinit.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 90 }) %}#}
{# {% endif %}#}
{% do assets.addInlineJs("
window.GravForm = window.GravForm || {};
window.GravForm = Object.assign({}, window.GravForm, {
translations: {
PLUGIN_FORM: {
'FILEPOND_REMOVE_FILE': " ~ 'PLUGIN_FORM.FILEPOND_REMOVE_FILE'|t|json_encode ~ ",
'FILEPOND_REMOVE_FILE_CONFIRMATION': " ~ 'PLUGIN_FORM.FILEPOND_REMOVE_FILE_CONFIRMATION'|t|json_encode ~ ",
'FILEPOND_CANCEL_UPLOAD': " ~ 'PLUGIN_FORM.FILEPOND_CANCEL_UPLOAD'|t|json_encode ~ ",
'FILEPOND_ERROR_FILESIZE': " ~ 'PLUGIN_FORM.FILEPOND_ERROR_FILESIZE'|t|json_encode ~ ",
'FILEPOND_ERROR_FILETYPE': " ~ 'PLUGIN_FORM.FILEPOND_ERROR_FILETYPE'|t|json_encode ~ "
}
}
});
", {'group': 'bottom', 'position': 'before'}) %}
{% do assets.addInlineJs("
document.addEventListener('DOMContentLoaded', function() {
if (typeof GravFormXHR !== 'undefined') {
// First check if DOM property exists
if (GravFormXHR.DOM && typeof GravFormXHR.DOM.updateFormContent === 'function') {
var originalUpdateFormContent = GravFormXHR.DOM.updateFormContent;
GravFormXHR.DOM.updateFormContent = function() {
var result = originalUpdateFormContent.apply(this, arguments);
// Dispatch event after form content is updated
setTimeout(function() {
document.dispatchEvent(new Event('grav-form-updated'));
if (window.reinitializeFilePonds) {
window.reinitializeFilePonds();
}
}, 50);
return result;
};
}
// If DOM property doesn't exist, try to hook into submit directly
else if (typeof GravFormXHR.submit === 'function') {
var originalSubmit = GravFormXHR.submit;
GravFormXHR.submit = function(form) {
var result = originalSubmit.apply(this, arguments);
// Reinitialize FilePond after form submission
setTimeout(function() {
document.dispatchEvent(new Event('grav-form-updated'));
if (window.reinitializeFilePonds) {
window.reinitializeFilePonds();
}
}, 500);
return result;
};
}
}
});
", {'group': 'bottom'}) %}
{% endblock %}

View file

@ -0,0 +1,393 @@
{% extends "forms/field.html.twig" %}
{% block label %}{% endblock %}
{% block input %}
{% set config = grav.config %}
{% set formId = form.id ?: form.name %}
{% set callbackId = formId|underscorize %}
{% set lang = grav.language.language %}
{# Get configuration values with fallbacks #}
{% set version = field.recaptcha_version ?? config.plugins.form.recaptcha.version ?? '2-checkbox' %}
{% set site_key = field.recaptcha_site_key ?? config.plugins.form.recaptcha.site_key %}
{% set theme = field.recaptcha_theme ?? config.plugins.form.recaptcha.theme ?? 'light' %}
{% if not site_key %}
<div class="form-error">reCAPTCHA site key is not set. Please set it in the form field or plugin configuration.</div>
{% else %}
{% if version == 3 or version == '3' %}
{# --- reCAPTCHA v3 Handling --- #}
{% set action = (page.route|trim('/') ~ '-' ~ form.name)|underscorize|md5 %}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-recaptcha-version="3"
data-captcha-provider="recaptcha"
data-intercepts-submit="true"
data-sitekey="{{ site_key }}"
data-version="3"
data-action="{{ action }}"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
{# Container for v3 - will be managed by JS #}
<input type="hidden" name="data[token]" value="">
<input type="hidden" name="data[action]" value="{{ action }}">
</div>
{% do assets.addJs('https://www.google.com/recaptcha/api.js?render=' ~ site_key, { group: 'bottom' }) %}
<script>
(function() {
window.GravRecaptchaInitializers = window.GravRecaptchaInitializers || {};
function addHiddenInput(form, name, value) {
const existing = form.querySelector('input[type="hidden"][name="' + name + '"]');
if (existing) {
existing.value = value;
} else {
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', name);
input.setAttribute('value', value);
form.insertBefore(input, form.firstChild);
}
}
function initRecaptchaV3(container) {
const formId = container.dataset.formId;
const siteKey = container.dataset.sitekey;
const action = container.dataset.action;
const form = document.getElementById(formId);
if (!form) return;
console.log(`Initializing reCAPTCHA v3 for form ${formId}`);
const submitHandler = function(event) {
event.preventDefault();
console.log(`reCAPTCHA v3 intercepting submit for form ${formId}`);
grecaptcha.ready(function() {
grecaptcha.execute(siteKey, { action: action })
.then(function(token) {
console.log(`reCAPTCHA v3 token received for form ${formId}`);
addHiddenInput(form, 'data[token]', token);
addHiddenInput(form, 'data[action]', action);
form.removeEventListener('submit', submitHandler);
if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
} else {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
setTimeout(() => {
const currentForm = document.getElementById(formId);
if (currentForm && !currentForm.dataset.recaptchaListenerAttached) {
currentForm.addEventListener('submit', submitHandler);
currentForm.dataset.recaptchaListenerAttached = 'true';
} else if (currentForm) {
delete currentForm.dataset.recaptchaListenerAttached;
}
}, 0);
});
});
};
delete form.dataset.recaptchaListenerAttached;
if (!form.dataset.recaptchaListenerAttached) {
form.addEventListener('submit', submitHandler);
form.dataset.recaptchaListenerAttached = 'true';
}
}
// Register the initializer function
const initializerFunctionName = 'initRecaptcha_{{ formId }}';
window.GravRecaptchaInitializers[initializerFunctionName] = function() {
const container = document.querySelector('[data-form-id="{{ formId }}"][data-captcha-provider="recaptcha"]');
if (!container) return;
initRecaptchaV3(container);
};
// Initial call
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.GravRecaptchaInitializers[initializerFunctionName]);
} else {
setTimeout(window.GravRecaptchaInitializers[initializerFunctionName], 0);
}
})();
</script>
{% elseif version == '2-invisible' %}
{# --- reCAPTCHA v2 Invisible Handling --- #}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-recaptcha-version="2-invisible"
data-captcha-provider="recaptcha"
data-intercepts-submit="true"
data-sitekey="{{ site_key }}"
data-version="2-invisible"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
{# Container for v2 invisible - will be managed by JS #}
<div id="g-recaptcha-{{ formId }}" class="g-recaptcha"></div>
</div>
<script>
(function() {
window.GravRecaptchaInitializers = window.GravRecaptchaInitializers || {};
function addHiddenInput(form, name, value) {
const existing = form.querySelector('input[type="hidden"][name="' + name + '"]');
if (existing) {
existing.value = value;
} else {
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', name);
input.setAttribute('value', value);
form.insertBefore(input, form.firstChild);
}
}
function initRecaptchaV2Invisible(container) {
const formId = container.dataset.formId;
const siteKey = container.dataset.sitekey;
const lang = container.dataset.lang;
const theme = container.dataset.theme;
const callbackId = container.dataset.callbackId || formId;
const form = document.getElementById(formId);
let widgetId = null;
if (!form) return;
console.log(`Initializing reCAPTCHA v2 Invisible for form ${formId}`);
const callbackName = 'captchaInvisibleOnloadCallback_' + callbackId;
if (typeof window[callbackName] !== 'function') {
window[callbackName] = function() {
console.log('reCAPTCHA Invisible API ready for form ' + formId);
};
if (!document.querySelector('script[src*="recaptcha/api.js?onload=' + callbackName + '"]')) {
const script = document.createElement('script');
script.src = 'https://www.google.com/recaptcha/api.js?onload=' + callbackName + '&hl=' + lang + '&theme=' + theme;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
}
const submitHandler = function(event) {
event.preventDefault();
console.log(`reCAPTCHA v2 Invisible intercepting submit for form ${formId}`);
if (typeof grecaptcha === 'undefined' || typeof grecaptcha.render === 'undefined') {
console.error('grecaptcha not ready for invisible captcha');
return;
}
const recaptchaId = 'g-recaptcha-' + formId;
let captchaElement = document.getElementById(recaptchaId);
if (!captchaElement) {
captchaElement = document.createElement('div');
captchaElement.setAttribute('id', recaptchaId);
captchaElement.className = 'g-recaptcha';
form.appendChild(captchaElement);
}
const renderCaptcha = () => {
if (widgetId !== null) {
try {
grecaptcha.reset(widgetId);
} catch (e) {
console.warn("Error resetting captcha", e);
}
}
widgetId = grecaptcha.render(recaptchaId, {
sitekey: siteKey,
size: 'invisible',
callback: function(token) {
console.log(`reCAPTCHA v2 Invisible token received for form ${formId}`);
addHiddenInput(form, 'g-recaptcha-response', token);
form.removeEventListener('submit', submitHandler);
if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
} else {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
setTimeout(() => {
const currentForm = document.getElementById(formId);
if (currentForm && !currentForm.dataset.recaptchaListenerAttached) {
currentForm.addEventListener('submit', submitHandler);
currentForm.dataset.recaptchaListenerAttached = 'true';
} else if (currentForm) {
delete currentForm.dataset.recaptchaListenerAttached;
}
}, 0);
}
});
grecaptcha.execute(widgetId);
};
if (typeof grecaptcha !== 'undefined' && grecaptcha.render) {
renderCaptcha();
} else {
const originalOnload = window[callbackName];
window[callbackName] = function() {
if(originalOnload) originalOnload();
renderCaptcha();
};
console.warn("grecaptcha object not found immediately, waiting for onload callback: " + callbackName);
}
};
delete form.dataset.recaptchaListenerAttached;
if (!form.dataset.recaptchaListenerAttached) {
form.addEventListener('submit', submitHandler);
form.dataset.recaptchaListenerAttached = 'true';
}
}
// Register the initializer function
const initializerFunctionName = 'initRecaptcha_{{ formId }}';
window.GravRecaptchaInitializers[initializerFunctionName] = function() {
const container = document.querySelector('[data-form-id="{{ formId }}"][data-captcha-provider="recaptcha"]');
if (!container) return;
initRecaptchaV2Invisible(container);
};
// Initial call
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.GravRecaptchaInitializers[initializerFunctionName]);
} else {
setTimeout(window.GravRecaptchaInitializers[initializerFunctionName], 0);
}
})();
</script>
{% else %}
{# --- reCAPTCHA v2 Checkbox Handling --- #}
{# Add script and container #}
{% set container_id = 'g-recaptcha-' ~ formId %}
{% set onloadCallback = 'captchaCheckboxOnloadCallback_' ~ callbackId %}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-captcha-provider="recaptcha"
data-sitekey="{{ site_key }}"
data-version="2-checkbox"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
<div id="{{ container_id }}" class="g-recaptcha"></div>
</div>
{% do assets.addJs('https://www.google.com/recaptcha/api.js?onload=' ~ onloadCallback ~ '&render=explicit', { 'group': 'bottom', 'loading': 'defer' }) %}
<script>
(function() {
// Explicit rendering for reCAPTCHA v2 Checkbox
window.GravExplicitCaptchaInitializers = window.GravExplicitCaptchaInitializers || {};
const initializerFunctionName = 'initExplicitCaptcha_{{ formId }}';
// Define the initializer function
window.GravExplicitCaptchaInitializers[initializerFunctionName] = function() {
const containerId = '{{ container_id }}';
const container = document.getElementById(containerId);
if (!container) {
console.warn('reCAPTCHA container #' + containerId + ' not found.');
return;
}
// Prevent re-rendering if widget already exists
if (container.innerHTML.trim() !== '' && container.querySelector('iframe')) {
return;
}
// Get configuration from parent container
const parentContainer = container.closest('.g-recaptcha-container');
if (!parentContainer) {
console.error('Cannot find parent container for #' + containerId);
return;
}
const sitekey = parentContainer.dataset.sitekey;
const theme = parentContainer.dataset.theme;
if (!sitekey) {
console.error('reCAPTCHA sitekey missing for #' + containerId);
return;
}
console.log('Attempting to render reCAPTCHA in #' + containerId);
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.render === 'function') {
try {
grecaptcha.render(containerId, {
sitekey: sitekey,
theme: theme,
callback: function(token) {
console.log('reCAPTCHA challenge successful for #' + containerId);
}
});
} catch (e) {
console.error('Error calling grecaptcha.render for #' + containerId, e);
container.innerHTML = '<p style="color:red;">Error initializing reCAPTCHA.</p>';
}
} else {
console.warn('grecaptcha API not available yet for #' + containerId + '. Waiting for onload.');
}
};
// Define the global onload callback
window['{{ onloadCallback }}'] = function() {
console.log('reCAPTCHA API loaded, triggering init for #{{ container_id }}');
if (window.GravExplicitCaptchaInitializers[initializerFunctionName]) {
window.GravExplicitCaptchaInitializers[initializerFunctionName]();
} else {
console.error("Initializer " + initializerFunctionName + " not found!");
}
};
// Form submit handler to check if captcha is completed
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('{{ formId }}');
if (form) {
form.addEventListener('submit', function(event) {
const response = grecaptcha.getResponse();
if (!response) {
event.preventDefault();
alert("{{ field.captcha_not_validated|t|default('Please complete the captcha')|e('js') }}");
} else if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
event.preventDefault();
window.GravFormXHR.submit(form);
}
});
}
});
})();
</script>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -1,9 +1,9 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<div class="form-spacer {{ field.classes }}">
<div class="form-field form-spacer {{ field.classes }}">
{% if field.title %}
<h3>{{- field.title|t|raw -}}</h3>
<{{ title_type|default('h3') }}>{{- field.title|t|raw -}}</ {{ title_type|default('h3') }}>
{% endif %}
{% if field.markdown %}

View file

@ -38,7 +38,7 @@
<div class="tabs-nav">
{% for tab in tabs %}
{% if tab.type == 'tab' and (tab.condition is null or tab.condition == true) %}
<a class="tab__link {{ (storedTab == scope ~ tab.name) or active == loop.index ? 'active' : '' }}" data-tabid="tab-{{ tabsKey ~ loop.index }}" data-tabkey="tab-{{ tabsKey }}" data-scope="{{ scope ~ tab.name }}">
<a class="tab__link {{ (storedTab == scope ~ tab.name) or active == loop.index ? 'active' : '' }}" data-tabid="tab-{{ tabsKey ~ '-' ~ tab.name }}" data-tabkey="tab-{{ tabsKey }}" data-scope="{{ scope ~ tab.name }}">
<span>{{ tab.title|t }}</span>
{% endif %}
</a>
@ -47,7 +47,7 @@
<div class="tabs-content">
{% embed 'forms/default/fields.html.twig' with {name: field.name, fields: fields} %}
{% block inner_markup_field_open %}
<div id="tab-{{ tabsKey ~ loop.index }}" class="tab__content {{ (storedTab == scope ~ field.name) or active == loop.index ? 'active' : '' }}">
<div id="tab-{{ tabsKey ~ '-' ~ field.name }}" class="tab__content {{ (storedTab == scope ~ field.name) or active == loop.index ? 'active' : '' }}">
{% endblock %}
{% block inner_markup_field_close %}
</div>

View file

@ -1,15 +1,107 @@
{% extends "forms/field.html.twig" %}
{% set config = grav.config %}
{% set site_key = field.turnstile_site_key ?? config.plugins.form.turnstile.site_key %}
{% set theme = field.theme ?? config.plugins.form.turnstile.theme ?? 'light' %}
{% block label %}{% endblock %}
{% block input %}
{% do assets.addJs('https://challenges.cloudflare.com/turnstile/v0/api.js', { defer: '', async: '' }) %}
{% set config = grav.config %}
{% set formId = form.id ?: form.name %}
<div class="turnstile">
<div class="cf-turnstile" data-sitekey="{{ site_key }}" data-theme="{{ theme }}"></div>
</div>
{% endblock %}
{# Get configuration values with fallbacks #}
{% set site_key = field.turnstile_site_key ?? config.plugins.form.turnstile.site_key %}
{% set theme = field.turnstile_theme ?? config.plugins.form.turnstile.theme ?? 'light' %}
{% set container_id = 'cf-turnstile-' ~ formId %}
{% set init_var = 'turnstile_initialized_' ~ formId %}
{% if not site_key %}
<div class="form-error">Turnstile site key is not set. Please set it in the form field or plugin configuration.</div>
{% else %}
{# Add a hidden field for the token directly in the INPUT block to ensure it's part of the field #}
<input type="hidden" name="cf-turnstile-response" value="" class="turnstile-token" />
<div class="turnstile-container"
data-form-id="{{ formId }}"
data-captcha-provider="turnstile"
data-sitekey="{{ site_key }}"
data-theme="{{ theme }}">
<div id="{{ container_id }}" class="cf-turnstile"></div>
</div>
{% do assets.addJs('https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback_' ~ formId ~ '&render=explicit', { 'loading': 'async', 'defer': '' }) %}
<script>
(function() {
// Prevent multiple initialization
if (window['{{ init_var }}']) {
console.log('Turnstile already initialized for form {{ formId }}');
return;
}
// Mark as initialized
window['{{ init_var }}'] = true;
// Use unique callback name to avoid conflicts with multiple forms
window['onloadTurnstileCallback_{{ formId }}'] = function() {
console.log('Turnstile API loaded - initializing widget for {{ formId }}');
const container = document.getElementById('{{ container_id }}');
if (!container) {
console.error('Turnstile container #{{ container_id }} not found');
return;
}
const form = document.getElementById('{{ formId }}');
if (!form) {
console.error('Cannot find form #{{ formId }}');
return;
}
// Find the token input field
const tokenField = form.querySelector('input[name="cf-turnstile-response"]');
if (!tokenField) {
console.error('Token field not found in form #{{ formId }}');
}
// Check if this container already has a widget (avoid duplicates)
if (container.querySelector('iframe')) {
console.log('Turnstile widget already initialized in this container');
return;
}
try {
turnstile.render('#{{ container_id }}', {
sitekey: '{{ site_key }}',
theme: '{{ theme }}',
callback: function(token) {
console.log('Turnstile callback fired with token', token.substring(0, 10) + '...');
// Update the token field
if (tokenField) {
tokenField.value = token;
console.log('Updated token field with value');
}
},
'expired-callback': function() {
console.warn('Turnstile token expired');
if (tokenField) {
tokenField.value = '';
}
},
'error-callback': function(error) {
console.error('Turnstile error:', error);
}
});
console.log('Turnstile render call completed');
} catch (e) {
console.error('Error initializing Turnstile:', e);
}
};
// Check if we can initialize immediately
if (document.getElementById('{{ container_id }}') && typeof turnstile !== 'undefined') {
window['onloadTurnstileCallback_{{ formId }}']();
}
})();
</script>
{% endif %}
{% endblock %}

View file

@ -26,9 +26,9 @@ You can also override individual fields by copying (using text field as an examp
templates/forms/fields/text/text.html.twig -> templates/forms/fields/text/tailwind-text.html.twig
#}
{% extends "forms/default/form.html.twig" %}
{% block xhr %}
{% include 'forms/layouts/xhr.html.twig' %}
{% endblock %}

View file

@ -6,3 +6,4 @@
{% block embed_fields %}{% endblock %}
{% block embed_buttons %}{% endblock %}
</form>

View file

@ -1,8 +1,25 @@
{% if form.xhr_submit == true %}
{% do assets.addJs('plugin://form/assets/xhr-submitter.js', {'group': 'bottom', 'position': 'before'}) %}
{# Ensure xhr-submitter.js is loaded BEFORE the inline JS that uses it #}
{% do assets.addJs('plugin://form/assets/xhr-submitter.js', {'group': 'bottom', 'priority': 101, 'position': 'before'}) %}
{% do assets.addInlineJs("
document.addEventListener('DOMContentLoaded', () => {
// This now primarily sets up the *potential* for XHR submission
// It might not attach the listener directly if recaptcha is present
attachFormSubmitListener('" ~ form.id ~ "');
// Re-run captcha initializers *if* the form was loaded via XHR initially
// This covers edge cases, might not be strictly needed if captcha script handles DOMContentLoaded
const formElement = document.getElementById('" ~ form.id ~ "');
if (formElement && window.GravRecaptchaInitializers) {
const initializerFuncName = 'initRecaptcha_" ~ form.id ~ "';
if (typeof window.GravRecaptchaInitializers[initializerFuncName] === 'function') {
// Check if it needs init (e.g., if container exists but no widget/listener)
// For simplicity, just call it again; the init function should be idempotent
// window.GravRecaptchaInitializers[initializerFuncName]();
}
}
});",
{'group': 'bottom', 'position': 'before'}) %}
{'group': 'bottom', 'priority': 100, 'position': 'before'}) %}
{% do assets.addJs('plugin://form/assets/captcha/recaptcha-handler.js', {'group': 'bottom', 'priority': 99, 'position': 'before'}) %}
{% do assets.addJs('plugin://form/assets/captcha/turnstile-handler.js', {'group': 'bottom', 'priority': 98, 'position': 'before'}) %}
{% endif %}

View file

@ -0,0 +1,133 @@
<?php
// Test script for basic captcha image generation
// Setup autoloading
require_once __DIR__ . '/vendor/autoload.php';
// Include the BasicCaptcha class
require_once __DIR__ . '/classes/Captcha/BasicCaptcha.php';
use Grav\Plugin\Form\Captcha\BasicCaptcha;
// Mock Grav instance for testing
class MockGrav {
public $config;
public $session;
public function __construct() {
$this->config = new MockConfig();
$this->session = new MockSession();
}
public function offsetGet($offset) {
return $this->$offset;
}
}
class MockConfig {
private $data = [
'plugins.form.basic_captcha' => [
'type' => 'math',
'image' => [
'width' => 135,
'height' => 40,
'bg' => '#ffffff'
],
'chars' => [
'font' => 'zxx-xed.ttf',
'size' => 16
],
'math' => [
'min' => 1,
'max' => 12,
'operators' => ['+', '-', '*']
]
]
];
public function get($key) {
return $this->data[$key] ?? null;
}
}
class MockSession {
private $data = [];
public function __set($key, $value) {
$this->data[$key] = $value;
}
public function __get($key) {
return $this->data[$key] ?? null;
}
}
// Override Grav instance
namespace Grav\Common;
class Grav {
private static $instance;
public static function instance() {
if (!self::$instance) {
self::$instance = new \MockGrav();
}
return self::$instance;
}
}
// Test the captcha
$captcha = new BasicCaptcha();
// Test different types
$types = ['math', 'characters'];
foreach ($types as $type) {
echo "Testing $type captcha...\n";
// Update config for type
Grav::instance()->config = new MockConfig();
$configData = [
'plugins.form.basic_captcha' => [
'type' => $type,
'image' => [
'width' => 135,
'height' => 40,
'bg' => '#ffffff'
],
'chars' => [
'font' => 'zxx-xed.ttf',
'size' => 16,
'length' => 6
],
'math' => [
'min' => 1,
'max' => 12,
'operators' => ['+', '-', '*']
]
]
];
// Generate captcha code
$code = $captcha->getCaptchaCode();
echo " Code: $code\n";
// Create image
$image = $captcha->createCaptchaImage($code);
// Check image dimensions
$width = imagesx($image);
$height = imagesy($image);
echo " Image dimensions: {$width}x{$height}\n";
// Save test image
$filename = "test-captcha-{$type}.jpg";
imagejpeg($image, $filename);
echo " Saved to: $filename\n";
// Clean up
imagedestroy($image);
echo "\n";
}
echo "Test complete! Check the generated test-captcha-*.jpg files.\n";

View file

@ -130,7 +130,7 @@ class ReCaptcha
* @param RequestMethod $requestMethod method used to send the request. Defaults to POST.
* @throws \RuntimeException if $secret is invalid
*/
public function __construct($secret, RequestMethod $requestMethod = null)
public function __construct($secret, ?RequestMethod $requestMethod = null)
{
if (empty($secret)) {
throw new \RuntimeException('No secret provided');

View file

@ -63,7 +63,7 @@ class CurlPost implements RequestMethod
* @param Curl $curl Curl resource
* @param string $siteVerifyUrl URL for reCAPTCHA siteverify API
*/
public function __construct(Curl $curl = null, $siteVerifyUrl = null)
public function __construct(?Curl $curl = null, $siteVerifyUrl = null)
{
$this->curl = (is_null($curl)) ? new Curl() : $curl;
$this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl;

View file

@ -57,7 +57,7 @@ class SocketPost implements RequestMethod
* @param \ReCaptcha\RequestMethod\Socket $socket optional socket, injectable for testing
* @param string $siteVerifyUrl URL for reCAPTCHA siteverify API
*/
public function __construct(Socket $socket = null, $siteVerifyUrl = null)
public function __construct(?Socket $socket = null, $siteVerifyUrl = null)
{
$this->socket = (is_null($socket)) ? new Socket() : $socket;
$this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl;

View file

@ -1,31 +1,41 @@
var webpack = require('webpack');
var path = require('path');
var UglifyJsPlugin = require('uglifyjs-webpack-plugin');
var TerserPlugin = require('terser-webpack-plugin');
var isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'production-wip';
var mode = isProd ? 'production' : 'development';
module.exports = {
entry: {
site: './app/main.js'
},
devtool: isProd ? false : 'eval-source-map',
mode: mode,
devtool: isProd ? false : 'source-map',
target: 'web',
output: {
path: path.resolve(__dirname, 'assets'),
filename: 'form.min.js',
chunkFilename: 'form.vendor.js'
filename: (pathData) => {
return pathData.chunk && pathData.chunk.name === 'site'
? 'form.min.js'
: `form.${pathData.chunk && pathData.chunk.name ? pathData.chunk.name : 'chunk'}.js`;
},
chunkFilename: 'form.[name].js'
},
optimization: {
minimize: isProd,
minimizer: [
new UglifyJsPlugin({
uglifyOptions: {
minimizer: isProd ? [
new TerserPlugin({
parallel: true,
extractComments: false,
terserOptions: {
compress: {
drop_console: true
},
dead_code: true
format: {
comments: false
}
}
})
],
] : [],
splitChunks: {
cacheGroups: {
vendors: {
@ -51,12 +61,18 @@ module.exports = {
rules: [
{ enforce: 'pre', test: /\.json$/, loader: 'json-loader' },
{ enforce: 'pre', test: /\.js$/, loader: 'eslint-loader', exclude: /node_modules/ },
{ test: /\.css$/, loader: 'style-loader!css-loader' },
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
query: {
options: {
presets: ['@babel/preset-env']
}
}

File diff suppressed because it is too large Load diff