array_merge($v, ['gatecraft_path']));
// Our own routes handle trailing slashes; stop WordPress redirecting them.
add_filter('redirect_canonical', fn($redirect) => get_query_var('gatecraft_path') !== '' ? false : $redirect);
// Early: send visitors to the site's own host, start the session and present the request as a page, so that themes and
// page builders set themselves up for it during template_redirect like for any other page.
add_action('template_redirect', function () {
$path = get_query_var('gatecraft_path');
if ($path === '' || $path === null) return;
gatecraft_canonical_host();
nocache_headers();
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Frame-Options: SAMEORIGIN');
if (!str_starts_with((string)$path, '/webhooks/')) gatecraft_session_start();
gatecraft_virtual_page();
}, 0);
// Late: answer the request after everyone else has run their template_redirect setup. Answering early skipped
// Elementor's own setup, so its script settings were missing and its header menus stopped working.
add_action('template_redirect', function () {
$path = get_query_var('gatecraft_path');
if ($path === '' || $path === null) return;
dispatch((array)apply_filters('gatecraft_public_routes', public_routes()), (string)$path);
exit;
}, PHP_INT_MAX - 100);
/**
* WordPress normally moves visitors from example.com to www.example.com (or the reverse) to match the site address,
* but our pages switch that redirect off for their own paths. Do the host part here: a page served on the other host
* would load its fonts and live price checks from the site address and the browser would block them as cross-site.
*/
function gatecraft_canonical_host(): void
{
$method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD']))) : 'GET';
if (!in_array($method, ['GET', 'HEAD'], true)) return;
$want = strtolower((string)wp_parse_url(home_url('/'), PHP_URL_HOST));
$have = strtolower((string)preg_replace('/:\d+$/', '', isset($_SERVER['HTTP_HOST']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])) : ''));
if ($want === '' || $have === '' || $want === $have) return;
// Only between the bare domain and its www form, never to an unrelated host.
if (preg_replace('/^www\./', '', $want) !== preg_replace('/^www\./', '', $have)) return;
$uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
if ($uri === '' || $uri[0] !== '/' || str_starts_with($uri, '//')) $uri = '/';
$scheme = (string)wp_parse_url(home_url('/'), PHP_URL_SCHEME) ?: 'https';
$port = wp_parse_url(home_url('/'), PHP_URL_PORT);
wp_safe_redirect($scheme . '://' . $want . ($port ? ':' . $port : '') . $uri, 301);
exit;
}
/**
* Our URLs resolve to no post, so WordPress would describe them as the blog home page (body class "home blog").
* Themes and page builders key headers, menus and overlays off that, which breaks the header on every Gatecraft page.
* Present the request as an ordinary page instead.
*/
function gatecraft_virtual_page(): void
{
global $wp_query;
if (!($wp_query instanceof \WP_Query)) return;
$virtual = new \WP_Post((object)[
'ID' => 0, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => '', 'post_name' => 'gatecraft',
'post_content' => '', 'post_excerpt' => '', 'comment_status' => 'closed', 'ping_status' => 'closed', 'filter' => 'raw',
]);
foreach (['is_home', 'is_front_page', 'is_404', 'is_archive', 'is_search', 'is_posts_page', 'is_post_type_archive'] as $flag) $wp_query->$flag = false;
$wp_query->is_page = true;
$wp_query->is_singular = true;
$wp_query->post = $virtual;
$wp_query->posts = [$virtual];
$wp_query->post_count = 1;
$wp_query->found_posts = 1;
$wp_query->queried_object = $virtual;
$wp_query->queried_object_id = 0;
// Sets the global $post (and its post data) from $wp_query->post, the virtual page, the way WordPress does after a loop.
wp_reset_postdata();
status_header(200);
}
function gatecraft_enqueue_front(): void
{
wp_enqueue_style('gatecraft', GATECRAFT_URL . 'assets/app.css', [], GATECRAFT_VERSION);
// Each value is checked for CSS here, where it is printed: strict hex colours with a fixed fallback, then css_value().
$brand = ['--brand' => css_hex(brand_color(), '#0b0b0c'), '--brand-dark' => css_hex(brand_shade(0.82), '#373737'), '--on-brand' => css_hex(brand_on_color(), '#ffffff')];
wp_add_inline_style('gatecraft', css_vars('.gatecraft', $brand) . css_vars('.gatecraft.gc-theme', theme_style_vars()));
wp_enqueue_script('gatecraft', GATECRAFT_URL . 'assets/js/app.js', [], GATECRAFT_VERSION, ['in_footer' => true, 'strategy' => 'defer']);
static $attached = false;
if (!$attached) { $attached = true; i18n_attach_script('gatecraft'); }
}
// Standalone pages (door scanner) send a strict Content-Security-Policy: our inline scripts carry the page nonce.
add_filter('wp_inline_script_attributes', function (array $attrs) {
if (isset($attrs['id']) && str_starts_with((string)$attrs['id'], 'gatecraft')) $attrs['nonce'] = csp_nonce();
return $attrs;
});
/*
* Ticketing pages inside the active theme. The page body is built from our templates and printed with wp_kses().
* Block themes: WordPress renders the page from the block template "page-gatecraft" (the theme's header, the
* gatecraft/page block, the theme's footer), registered below, through its own template canvas. Classic themes:
* templates/theme_page.php prints the theme's header, the page body and the theme's footer.
*/
add_action('init', function () {
register_block_type('gatecraft/page', [
'api_version' => 3,
'title' => 'Gatecraft ticketing page',
'category' => 'widgets',
'supports' => ['inserter' => false, 'html' => false, 'reusable' => false],
'render_callback' => fn() => '
Gatecraft: add the event address, for example [gatecraft_event slug="summer-night"].
' : ''; gatecraft_enqueue_front(); gatecraft_session_start(); $data = event_page_data($slug); if (!$data) return current_user_can('edit_posts') ? 'Gatecraft: no published event has the address "' . esc_html($slug) . '".
' : ''; unset($data['title']); return ''; } /** Start the booking session before output on pages that carry [gatecraft_event], and keep them out of page caches. */ add_action('template_redirect', function () { if (!is_singular()) return; $post = get_queried_object(); if (!$post instanceof \WP_Post) return; $has = has_shortcode((string)$post->post_content, 'gatecraft_event') || str_contains((string)get_post_meta($post->ID, '_elementor_data', true), 'gatecraft_event'); if (!$has) return; if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the shared constant page cache plugins read nocache_headers(); gatecraft_session_start(); }, 1); function gatecraft_shortcode_button($atts): string { $a = shortcode_atts(['event' => '', 'label' => 'Get tickets'], $atts, 'gatecraft_button'); if (!preg_match('/^[a-z0-9-]{1,120}$/', (string)$a['event'])) return ''; gatecraft_enqueue_front(); return ''; } /* ------------------------------------------------------------------ */ /* wp-admin: "Tickets" menu */ /* ------------------------------------------------------------------ */ const GATECRAFT_ADMIN_PAGES = [ 'gatecraft' => ['Dashboard', 'gatecraft_checkin', ''], 'gatecraft-events' => ['Events', 'gatecraft_manage', '/events'], 'gatecraft-venues' => ['Venues', 'gatecraft_manage', '/venues'], 'gatecraft-discounts' => ['Discounts', 'gatecraft_manage', '/discounts'], 'gatecraft-orders' => ['Bookings', 'gatecraft_manage', '/orders'], 'gatecraft-reports' => ['Reports and exports', 'gatecraft_manage', '/reports'], 'gatecraft-checkin' => ['Check-in', 'gatecraft_checkin', '/checkin'], 'gatecraft-users' => ['Staff and roles', 'gatecraft_admin', '/users'], 'gatecraft-settings' => ['Settings', 'gatecraft_admin', '/settings'], 'gatecraft-emails' => ['Email log', 'gatecraft_admin', '/emails'], 'gatecraft-audit' => ['Audit log', 'gatecraft_admin', '/audit'], ]; add_action('admin_menu', function () { // Add-ons can show a count of items waiting for staff on the menu (and on the submenu page it names). [$pending, $bubblePage] = (array)apply_filters('gatecraft_menu_count', [0, '']) + [0, '']; $pending = current_user_can('gatecraft_manage') ? (int)$pending : 0; $bubble = $pending ? ' ' . $pending . '' : ''; add_menu_page('Tickets', 'Tickets' . $bubble, 'gatecraft_checkin', 'gatecraft', __NAMESPACE__ . '\\gatecraft_admin_page', 'dashicons-tickets-alt', 26); foreach (admin_pages() as $slug => [$label, $cap]) { add_submenu_page('gatecraft', $label . ' | Tickets', $label . ($slug === $bubblePage ? $bubble : ''), $cap, $slug, __NAMESPACE__ . '\\gatecraft_admin_page'); } }); /** Hosts the plugin hands visitors to: Stripe Checkout and Stripe's konbini payment slips. Add-ons add their own. */ function gatecraft_redirect_hosts(array $hosts): array { return array_merge($hosts, ['checkout.stripe.com', 'payments.stripe.com']); } add_filter('allowed_redirect_hosts', __NAMESPACE__ . '\\gatecraft_redirect_hosts'); /** * The internal route for the current admin request, e.g. "/admin/events/5/tickets". Read-only: it only selects which * registered screen to show. ?page= must be one of our menu pages and ?r= must match a registered admin route for the * request method (a strict allowlist, see route_known()); anything else falls back to the menu page's own route. The route never changes data by * itself: a POST to it still has to pass the nonce, session token and capability checks in dispatch(). */ function gatecraft_admin_route(): string { $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: selects a menu page from admin_pages() $pages = admin_pages(); $base = isset($pages[$page]) ? (string)$pages[$page][2] : ''; $r = isset($_GET['r']) ? sanitize_text_field(wp_unslash($_GET['r'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: compared with the registered routes below $method = server('REQUEST_METHOD') === 'POST' ? 'POST' : 'GET'; if ($r !== '' && preg_match('#^/[a-z0-9/_-]{1,120}$#', $r) && route_known(all_admin_routes(), $method, '/admin' . $r)) $base = $r; return '/admin' . $base; } function gatecraft_is_our_admin_page(): bool { return is_admin() && isset($_GET['page']) && array_key_exists(sanitize_key(wp_unslash($_GET['page'])), admin_pages()); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check of which admin page is open } // POSTs, downloads and the full-screen scanner run before wp-admin prints anything. add_action('admin_init', function () { // admin-ajax.php and admin-post.php also run admin_init; a page= in their address must not reach our routes. if (!gatecraft_is_our_admin_page() || wp_doing_ajax() || basename((string)server('SCRIPT_NAME')) === 'admin-post.php') return; // Only ticketing staff reach any Tickets screen; each route then checks its own capability in dispatch(). if (!current_user_can('gatecraft_checkin') && !current_user_can('gatecraft_manage') && !current_user_can('gatecraft_admin')) { wp_die(esc_html('You are not allowed to access this page.'), '', ['response' => 403]); } $allow = admin_allowlist(); if ($allow && !ip_in_list(client_ip(), $allow)) wp_die(esc_html('Not available from this network.'), '', ['response' => 404]); gatecraft_session_start(); $route = gatecraft_admin_route(); // Staff who cannot see the dashboard (door staff) go straight to Check-in, before anything is printed. if ($route === '/admin' && (!current_user_can('gatecraft_manage') || !can('reports'))) { wp_safe_redirect(map_url('/admin/checkin')); exit; } $standalone = preg_match('#^/admin/checkin/\d+$#', $route) || str_starts_with($route, '/admin/export/') || $route === '/admin/audience/export'; if (server('REQUEST_METHOD') === 'POST' || $standalone) { dispatch(all_admin_routes(), $route); exit; } }); function gatecraft_admin_page(): void { $GLOBALS['gatecraft_admin_rendering'] = true; $level = ob_get_level(); // Held in a buffer so a page that stops with an error (abort()) can be replaced by the error screen. ob_start(); try { dispatch(all_admin_routes(), gatecraft_admin_route()); ob_end_flush(); } catch (AdminAbort $ex) { // Drop what the handler started printing and show the error screen in its place. while (ob_get_level() > $level) ob_end_clean(); render('admin/error', (array)($GLOBALS['gatecraft_admin_error'] ?? ['code' => 500, 'title' => 'Something went wrong', 'message' => '', 'back' => '/admin', 'backLabel' => 'Back to dashboard'])); } finally { $GLOBALS['gatecraft_admin_rendering'] = false; } } add_action('admin_enqueue_scripts', function () { if (!gatecraft_is_our_admin_page()) return; wp_enqueue_style('gatecraft-admin', GATECRAFT_URL . 'assets/app.css', [], GATECRAFT_VERSION); // The rich text editor's toolbar styles, in the (wp_editor() would otherwise print them inside the page body). wp_enqueue_style('editor-buttons'); // The admin screens always use the Gatecraft look; the organiser's colour only reaches the previews of their pages. // Each value is checked for CSS here, where it is printed: strict hex colours with a fixed fallback. $admin = ['--brand' => '#0b0b0c', '--brand-dark' => '#373737', '--on-brand' => '#ffffff']; $brand = ['--brand' => css_hex(brand_color(), '#0b0b0c'), '--brand-dark' => css_hex(brand_shade(0.82), '#373737'), '--on-brand' => css_hex(brand_on_color(), '#ffffff')]; wp_add_inline_style('gatecraft-admin', css_vars('.gatecraft', $admin) . css_vars('.gatecraft .brand-preview,.gatecraft .ev-page', $brand)); wp_enqueue_script('gatecraft-admin', GATECRAFT_URL . 'assets/js/app.js', [], GATECRAFT_VERSION, ['in_footer' => true, 'strategy' => 'defer']); i18n_attach_script('gatecraft-admin'); admin_palette_attach('gatecraft-admin'); // Add-ons load their own scripts for the screen being shown (the allowlisted route, without the "/admin" prefix). do_action('gatecraft_admin_enqueue', substr(gatecraft_admin_route(), strlen('/admin'))); }); add_filter('plugin_action_links_' . plugin_basename(GATECRAFT_FILE), function ($links) { if (current_user_can('gatecraft_admin')) array_unshift($links, 'Settings'); return $links; }); /* ------------------------------------------------------------------ */ /* Sign-in hardening for the whole WordPress login */ /* ------------------------------------------------------------------ */ function gatecraft_user_needs_2fa(WP_User $u): bool { return staff_2fa_required() && ($u->has_cap('gatecraft_checkin') || $u->has_cap('manage_options')); } function rate_limit_hits(string $bucket, string $id, int $window): int { $t = time(); $row = q1('SELECT hits, window_start FROM {rate_limits} WHERE k = ?', [hash('sha256', $bucket . '|' . ip_bucket($id))]); return $row && (int)$row['window_start'] === $t - ($t % $window) ? (int)$row['hits'] : 0; } /** * Lockout buckets: per IP, per account from one IP, and a much higher per-account ceiling across all IPs. * Keying the account bucket on the IP stops a stranger from locking a real admin out with a few wrong guesses. */ function gatecraft_login_keys(string $username): array { $u = get_user_by(is_email($username) ? 'email' : 'login', $username); $acct = $u ? 'id:' . $u->ID : 'name:' . strtolower(remove_accents($username)); return [['wplogin-ip', client_ip(), 4], ['wplogin-user', $acct . '|' . client_ip(), 1], ['wplogin-user-all', $acct, 20]]; } // Lockout after repeated failures, per account and per IP. Runs last, so it also blocks a correct password while locked. add_filter('authenticate', function ($user, $username) { if (!$username) return $user; $win = setting_int('login_lock_minutes', 1, 1440) * 60; $max = setting_int('login_max_attempts', 3, 20); foreach (gatecraft_login_keys((string)$username) as [$b, $id, $mult]) { if (rate_limit_hits($b, $id, $win) >= $max * $mult) { return new WP_Error('gatecraft_locked', sprintf('Too many failed sign-in attempts. Try again in %d minutes.', (int)ceil($win / 60))); } } if ($user instanceof WP_User) { if (account_row($user->ID)['status'] === 'disabled') return new WP_Error('gatecraft_disabled', 'This account is disabled.'); // XML-RPC has no second step, so staff who need 2FA cannot use it. if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST && gatecraft_user_needs_2fa($user)) return new WP_Error('gatecraft_2fa', 'Two-step verification is required for this account.'); } return $user; }, 99, 2); add_action('wp_login_failed', function ($username, $error = null) { // Attempts made while already locked out do not extend the lockout. if ($error instanceof WP_Error && $error->get_error_code() === 'gatecraft_locked') return; $win = setting_int('login_lock_minutes', 1, 1440) * 60; foreach (gatecraft_login_keys((string)$username) as [$b, $id]) rate_limit($b, $id, PHP_INT_MAX, $win); }, 10, 2); // Application passwords skip any second step, so they are off for accounts that need 2FA. add_filter('wp_is_application_passwords_available_for_user', fn($ok, $user) => $ok && !gatecraft_user_needs_2fa($user), 10, 2); // Remember the session token WordPress just created, so the 2FA step can cancel it. // $expire is 0 for a session cookie and a date when the person ticked "Remember Me". add_action('set_auth_cookie', function ($cookie, $expire, $expiration, $userId, $scheme, $token) { $GLOBALS['gatecraft_new_token'] = [$userId, $token, (int)$expire > 0]; }, 10, 6); // A nonce on the WordPress login form, so the 2FA step below can trust the page the person wanted to go to. add_action('login_form', function () { wp_nonce_field('gatecraft_login', 'gatecraft_login_nonce', false); }); // Second step for staff after a correct password: cancel the new session and ask for the authenticator code. add_action('wp_login', function ($login, $user) { if (!empty($GLOBALS['gatecraft_login_method']) || !$user instanceof WP_User || !gatecraft_user_needs_2fa($user)) return; [$uid, $token, $remember] = ($GLOBALS['gatecraft_new_token'] ?? []) + [0, '', false]; if ((int)$uid === (int)$user->ID && $token) WP_Session_Tokens::get_instance((int)$user->ID)->destroy((string)$token); // Where to go after the code: the login form's redirect_to, accepted only with our form nonce; otherwise the dashboard. $next = admin_url(); $verified = false; if (isset($_POST['gatecraft_login_nonce'])) { $verified = (bool)wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['gatecraft_login_nonce'])), 'gatecraft_login'); } if ($verified && isset($_POST['redirect_to'])) { $next = wp_validate_redirect(esc_url_raw(wp_unslash($_POST['redirect_to'])), admin_url()); } gatecraft_begin_2fa($user, $next, (bool)$remember); }, 1, 2); /** Send a user who passed the first factor (password or Google) to the authenticator step. */ function gatecraft_begin_2fa(WP_User $user, string $next, bool $remember, string $via = 'password'): never { wp_clear_auth_cookie(); wp_set_current_user(0); gatecraft_session_start(); gatecraft_session_regenerate(); $state = ['uid' => $user->ID, 'at' => time(), 'next' => $next, 'remember' => $remember, 'via' => $via]; account_touch($user->ID); $acc = account_row($user->ID); sess_del('pending_2fa', 'enroll_2fa'); if ((int)$acc['totp_enabled'] === 1 && trust_valid((int)$user->ID)) { // This browser was remembered after an authenticator code: the password (or Google) was enough this time. login_user((int)$user->ID, $via . '+totp-remembered', $remember); wp_safe_redirect($next); exit; } if ((int)$acc['totp_enabled'] === 1) sess_set('pending_2fa', $state); else sess_set('enroll_2fa', $state + ['secret' => totp_new_secret()]); gatecraft_session_save(); wp_safe_redirect(url('/login/2fa')); exit; } /* * 2FA is enforced on the session itself, not only at login. A session created by login_user() (after the * authenticator code) carries an gatecraft_2fa mark. Any session of a user who needs 2FA without that mark is * refused and destroyed: sessions from before 2FA was switched on, users promoted to staff on the core * Users screen, and logins made by other plugins that skip the wp_login action. */ add_filter('attach_session_information', function ($info, $userId) { if (!empty($GLOBALS['gatecraft_2fa_passed']) && (int)$GLOBALS['gatecraft_2fa_passed'] === (int)$userId) $info['gatecraft_2fa'] = time(); return $info; }, 10, 2); add_filter('determine_current_user', function ($uid) { if (!$uid || !staff_2fa_required()) return $uid; $u = get_userdata((int)$uid); if (!$u || !gatecraft_user_needs_2fa($u)) return $uid; $tokens = WP_Session_Tokens::get_instance((int)$uid); foreach (['logged_in', ''] as $scheme) { $c = wp_parse_auth_cookie('', $scheme); if (!$c || empty($c['token']) || $c['username'] !== $u->user_login) continue; $sess = $tokens->get($c['token']); if ($sess && !empty($sess['gatecraft_2fa'])) return $uid; if ($sess) $tokens->destroy($c['token']); } return 0; }, 30); // Staff sessions are capped at the length set in Settings, even with "Remember me". add_filter('auth_cookie_expiration', function ($expiration, $userId) { $u = get_userdata((int)$userId); if ($u && $u->has_cap('gatecraft_checkin')) return min((int)$expiration, setting_int('staff_session_hours', 1, 24) * 3600); return $expiration; }, 10, 2); /** Plain security notice to the account owner, sent immediately (not queued), so a takeover attempt is visible. */ function gatecraft_notify_security_change(int $userId, string $what): void { $u = get_userdata($userId); if (!$u) return; $body = $what . ".\n\nTime: " . ldate('D j M Y, H:i T') . "\nIP address: " . client_ip() . "\nSite: " . home_url('/') . "\n\nIf this was not you, contact the site administrator straight away so your access can be reset."; wp_mail($u->user_email, '[' . wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES) . '] Security change on your account', $body); audit('security.notice', 'user', $userId, ['what' => $what]); } // Logging out of WordPress also ends the ticketing session (form drafts, 2FA state). add_action('wp_logout', function () { gatecraft_session_start(); gatecraft_session_destroy(); }); add_action('deleted_user', function ($id) { q('DELETE FROM {accounts} WHERE user_id = ?', [(int)$id]); q('UPDATE {orders} SET user_id = NULL WHERE user_id = ?', [(int)$id]); q('DELETE FROM {notifications} WHERE user_id = ?', [(int)$id]); }); /* ------------------------------------------------------------------ */ /* Background work: WP-Cron every minute, or WP-CLI from a real cron */ /* ------------------------------------------------------------------ */ add_filter('cron_schedules', function ($s) { $s['gatecraft_quarter_hour'] = ['interval' => 900, 'display' => 'Every 15 minutes (Gatecraft)']; return $s; }); /** The background work runs every 15 minutes, and right away when there is mail to send (gatecraft_kick_worker()). Sites set up with the older one-minute schedule move to it. */ function gatecraft_schedule_worker(): void { if (wp_get_schedule('gatecraft_worker') === 'gatecraft_quarter_hour') return; wp_clear_scheduled_hook('gatecraft_worker'); wp_schedule_event(time() + 60, 'gatecraft_quarter_hour', 'gatecraft_worker'); } add_action('admin_init', __NAMESPACE__ . '\\gatecraft_schedule_worker'); /** Run the background work as soon as possible (for example right after an email is queued), once. */ function gatecraft_kick_worker(): void { if (!wp_next_scheduled('gatecraft_worker', ['soon'])) wp_schedule_single_event(time(), 'gatecraft_worker', ['soon']); } add_action('gatecraft_worker', fn() => gatecraft_run_worker()); function gatecraft_run_worker(): array { if (get_transient('gatecraft_worker_lock')) return ['skipped' => true]; set_transient('gatecraft_worker_lock', 1, 120); try { $r = ['expired' => expire_orders(), 'summary' => send_daily_summary()]; $r = (array)apply_filters('gatecraft_worker_jobs', $r); $r['emails'] = process_email_queue(50); q('DELETE FROM {rate_limits} WHERE window_start < ?', [time() - 86400]); q('DELETE FROM {sessions} WHERE expires < ?', [time()]); q('DELETE FROM {notifications} WHERE read_at IS NOT NULL AND read_at < ?', [ldate('Y-m-d H:i:s', lstrtotime('-90 days'))]); return $r; } finally { delete_transient('gatecraft_worker_lock'); } } if (defined('WP_CLI') && WP_CLI) { WP_CLI::add_command('gatecraft worker', function () { $r = gatecraft_run_worker(); WP_CLI::success(wp_json_encode($r)); }); WP_CLI::add_command('gatecraft reset-2fa', function ($args) { $u = get_user_by(is_email($args[0] ?? '') ? 'email' : 'login', $args[0] ?? ''); if (!$u) WP_CLI::error('User not found.'); q('UPDATE {accounts} SET totp_secret_enc = NULL, totp_enabled = 0, totp_last_step = NULL WHERE user_id = ?', [$u->ID]); revoke_sessions($u->ID); gatecraft_notify_security_change($u->ID, 'The authenticator app on your account was reset from the server. You will set up a new one at your next sign-in'); WP_CLI::success('Authenticator reset for ' . $u->user_email . '. They will set it up again at next sign-in.'); }); WP_CLI::add_command('gatecraft unlock', function ($args) { $u = get_user_by(is_email($args[0] ?? '') ? 'email' : 'login', $args[0] ?? ''); if (!$u) WP_CLI::error('User not found.'); // Counter keys are hashed (they include IPs), so clear them all. This only resets failure and rate counters. q('DELETE FROM {rate_limits}'); audit('login.unlocked', 'user', $u->ID, ['via' => 'wp-cli']); WP_CLI::success('Sign-in lockouts cleared. ' . $u->user_login . ' can sign in again.'); }); } /* ------------------------------------------------------------------ */ /* Personal data: Tools > Export / Erase Personal Data */ /* ------------------------------------------------------------------ */ add_filter('wp_privacy_personal_data_exporters', function ($e) { $e['gatecraft'] = ['exporter_friendly_name' => 'Gatecraft bookings', 'callback' => function ($email) { $data = []; foreach (q('SELECT o.*, e.title FROM {orders} o JOIN {events} e ON e.id = o.event_id WHERE o.buyer_email = ?', [strtolower($email)])->fetchAll() as $o) { $data[] = ['group_id' => 'gatecraft-orders', 'group_label' => 'Ticket bookings', 'item_id' => 'gatecraft-order-' . $o['id'], 'data' => [ ['name' => 'Reference', 'value' => $o['reference']], ['name' => 'Event', 'value' => $o['title']], ['name' => 'Name', 'value' => $o['buyer_name']], ['name' => 'Phone', 'value' => (string)$o['buyer_phone']], ['name' => 'Status', 'value' => status_label($o['status'])], ['name' => 'Total', 'value' => money((int)$o['total'])], ['name' => 'Answers', 'value' => implode('; ', array_map(fn($a) => $a['q'] . ': ' . $a['a'], answers_list($o['answers'])))], ['name' => 'Created', 'value' => $o['created_at']]]]; } $email = strtolower($email); $c = q1('SELECT * FROM {contacts} WHERE email = ?', [$email]); if ($c) $data[] = ['group_id' => 'gatecraft-contact', 'group_label' => 'Mailing list', 'item_id' => 'gatecraft-contact-' . $c['id'], 'data' => [ ['name' => 'Name', 'value' => $c['name']], ['name' => 'Status', 'value' => $c['status']], ['name' => 'Consent given', 'value' => (string)$c['consent_at']], ['name' => 'Consent wording', 'value' => (string)$c['consent_text']], ['name' => 'Unsubscribed', 'value' => (string)$c['unsubscribed_at']], ['name' => 'Added', 'value' => $c['created_at']]]]; if (eventpack_has_table('waitlist')) foreach (q('SELECT w.*, e.title FROM {waitlist} w JOIN {events} e ON e.id = w.event_id WHERE w.email = ?', [$email])->fetchAll() as $w) { $data[] = ['group_id' => 'gatecraft-waitlist', 'group_label' => 'Waiting lists', 'item_id' => 'gatecraft-waitlist-' . $w['id'], 'data' => [ ['name' => 'Event', 'value' => $w['title']], ['name' => 'Name', 'value' => $w['name']], ['name' => 'Places', 'value' => (string)$w['qty']], ['name' => 'Status', 'value' => $w['status']], ['name' => 'Joined', 'value' => $w['created_at']]]]; } return ['data' => $data, 'done' => true]; }]; return $e; }); add_filter('wp_privacy_personal_data_erasers', function ($e) { $e['gatecraft'] = ['eraser_friendly_name' => 'Gatecraft bookings', 'callback' => function ($email) { // Orders for events that have not happened yet are kept, because the tickets are still in use. $rows = q('SELECT o.id FROM {orders} o JOIN {events} e ON e.id = o.event_id WHERE o.buyer_email = ? AND (e.starts_at < ? OR o.status NOT IN (\'paid\',\'pending_payment\',\'awaiting_approval\'))', [strtolower($email), now()])->fetchAll(Result::COLUMN); foreach ($rows as $id) q("UPDATE {orders} SET buyer_name = 'Deleted', buyer_email = CONCAT('deleted-', id, '@invalid'), buyer_phone = NULL, answers = NULL, user_id = NULL WHERE id = ?", [$id]); $email = strtolower($email); $other = 0; $other += q('DELETE FROM {contacts} WHERE email = ?', [$email])->rowCount(); if (eventpack_has_table('waitlist')) $other += q('DELETE FROM {waitlist} WHERE email = ?', [$email])->rowCount(); $other += q('DELETE FROM {email_queue} WHERE to_email = ?', [$email])->rowCount(); $wpUser = get_user_by('email', $email); if ($wpUser) $other += q('DELETE FROM {trusted_browsers} WHERE user_id = ?', [$wpUser->ID])->rowCount(); $kept = (int)qv('SELECT COUNT(*) FROM {orders} WHERE buyer_email = ?', [$email]); return ['items_removed' => count($rows) > 0 || $other > 0, 'items_retained' => $kept > 0, 'messages' => $kept ? ['Bookings for upcoming events were kept so the tickets stay valid.'] : [], 'done' => true]; }]; return $e; }); // Ticket pages need "pretty" permalinks (/events/...). Say so plainly instead of failing with 404s: on the Tickets // screens, the Plugins screen and the Permalinks screen only. add_action('admin_notices', function () { if (get_option('permalink_structure') !== '' || !current_user_can('gatecraft_admin')) return; $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!gatecraft_is_our_admin_page() && !in_array($screen->id ?? '', ['plugins', 'options-permalink'], true)) return; echo 'Gatecraft: ticket pages need pretty permalinks. Go to Settings > Permalinks and choose any option other than "Plain". Until then, buyers cannot open the ticket pages.
' . esc_html('Gatecraft Pro is not active.') . ' ' . esc_html(implode(' ', $lines)) . ' ' . esc_html('Activate Gatecraft Pro to turn them back on, or') . ' ' . wp_kses($clear, ['form' => ['method' => [], 'action' => [], 'style' => [], 'data-confirm' => []], 'input' => ['type' => [], 'name' => [], 'value' => []], 'button' => ['type' => [], 'class' => []]]) . '. ' . esc_html('Dismiss') . '