(Grav GitSync) Automatic Commit from smokephil
This commit is contained in:
parent
d73d0ba519
commit
96a01e3ab4
260 changed files with 25905 additions and 16011 deletions
4
plugins/form/.gitignore
vendored
4
plugins/form/.gitignore
vendored
|
|
@ -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
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
128
plugins/form/assets/captcha/basic-captcha-refresh.js
Normal file
128
plugins/form/assets/captcha/basic-captcha-refresh.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
166
plugins/form/assets/captcha/recaptcha-handler.js
Normal file
166
plugins/form/assets/captcha/recaptcha-handler.js
Normal 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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
121
plugins/form/assets/captcha/turnstile-handler.js
Normal file
121
plugins/form/assets/captcha/turnstile-handler.js
Normal 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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
311
plugins/form/assets/dropzone-reinit.js
Normal file
311
plugins/form/assets/dropzone-reinit.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
662
plugins/form/assets/filepond-handler.js
Normal file
662
plugins/form/assets/filepond-handler.js
Normal 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');
|
||||
})();
|
||||
141
plugins/form/assets/filepond-reinit.js
Normal file
141
plugins/form/assets/filepond-reinit.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
9
plugins/form/assets/filepond/filepond-plugin-file-validate-size.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond-plugin-file-validate-size.min.js
vendored
Normal 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});
|
||||
9
plugins/form/assets/filepond/filepond-plugin-file-validate-type.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond-plugin-file-validate-type.min.js
vendored
Normal 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});
|
||||
8
plugins/form/assets/filepond/filepond-plugin-image-preview.min.css
vendored
Normal file
8
plugins/form/assets/filepond/filepond-plugin-image-preview.min.css
vendored
Normal 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}
|
||||
9
plugins/form/assets/filepond/filepond-plugin-image-preview.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond-plugin-image-preview.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
plugins/form/assets/filepond/filepond-plugin-image-resize.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond-plugin-image-resize.min.js
vendored
Normal 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});
|
||||
9
plugins/form/assets/filepond/filepond-plugin-image-transform.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond-plugin-image-transform.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
plugins/form/assets/filepond/filepond.min.css
vendored
Normal file
8
plugins/form/assets/filepond/filepond.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
plugins/form/assets/filepond/filepond.min.js
vendored
Normal file
9
plugins/form/assets/filepond/filepond.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
277
plugins/form/assets/form.min.js
vendored
277
plugins/form/assets/form.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
596
plugins/form/classes/Captcha/BasicCaptcha.php
Normal file
596
plugins/form/classes/Captcha/BasicCaptcha.php
Normal 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");
|
||||
}
|
||||
}
|
||||
134
plugins/form/classes/Captcha/BasicCaptchaProvider.php
Normal file
134
plugins/form/classes/Captcha/BasicCaptchaProvider.php
Normal 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';
|
||||
}
|
||||
}
|
||||
84
plugins/form/classes/Captcha/CaptchaFactory.php
Normal file
84
plugins/form/classes/Captcha/CaptchaFactory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
244
plugins/form/classes/Captcha/CaptchaManager.php
Normal file
244
plugins/form/classes/Captcha/CaptchaManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
33
plugins/form/classes/Captcha/CaptchaProviderInterface.php
Normal file
33
plugins/form/classes/Captcha/CaptchaProviderInterface.php
Normal 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;
|
||||
}
|
||||
252
plugins/form/classes/Captcha/ReCaptchaProvider.php
Normal file
252
plugins/form/classes/Captcha/ReCaptchaProvider.php
Normal 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';
|
||||
}
|
||||
}
|
||||
134
plugins/form/classes/Captcha/TurnstileProvider.php
Normal file
134
plugins/form/classes/Captcha/TurnstileProvider.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8861
plugins/form/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
128
plugins/form/templates/forms/fields/filepond/filepond.html.twig
Normal file
128
plugins/form/templates/forms/fields/filepond/filepond.html.twig
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@
|
|||
{% block embed_fields %}{% endblock %}
|
||||
{% block embed_buttons %}{% endblock %}
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
133
plugins/form/test-captcha.php
Normal file
133
plugins/form/test-captcha.php
Normal 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";
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue