?"; $args = [now()]; if ($q !== '') { $like = '%' . addcslashes($q, '%_\\') . '%'; $sql .= ' AND (e.title LIKE ? OR e.summary LIKE ? OR v.name LIKE ? OR v.city LIKE ?)'; array_push($args, $like, $like, $like, $like); } if ($cat !== '') { $sql .= ' AND e.category = ?'; $args[] = $cat; } $events = q($sql . ' ORDER BY e.starts_at LIMIT 60', $args)->fetchAll(); foreach ($events as &$ev) { $offer = event_offer($ev); $ev['offer'] = $offer; $ev['from_price'] = event_from_price($offer); $ev['state'] = event_state_label($offer); } unset($ev); // Search and category filters only help when there is a lot to search through. $total = (int)qv("SELECT COUNT(*) FROM {events} WHERE status = 'published' AND visibility = 'public' AND starts_at > ?", [now()]); $filtering = $q !== '' || $cat !== ''; // The spotlight is the soonest event. It is left out of the list below so nothing appears twice. $spotlight = null; if (setting('hero_style') === 'next' && !$filtering && $events) { $spotlight = array_shift($events); $spotlight['when'] = event_when_label($spotlight); } $cats = q("SELECT DISTINCT category FROM {events} WHERE status = 'published' AND visibility = 'public' AND starts_at > ? AND category IS NOT NULL AND category <> '' ORDER BY category", [now()])->fetchAll(Result::COLUMN); render('public/home', ['title' => setting('brand_name'), 'events' => $events, 'cats' => $cats, 'q' => $q, 'cat' => $cat, 'spotlight' => $spotlight, 'show_filters' => $total > HOME_FILTER_MIN]); } /** Search and category chips appear on the events page once more events than this are on sale. */ const HOME_FILTER_MIN = 8; /** "Today", "Tomorrow" or "In 5 days" for events up to two weeks away; empty further out. */ function event_when_label(array $ev): string { $days = (int)(new \DateTimeImmutable(substr(now(), 0, 10)))->diff(new \DateTimeImmutable(substr((string)$ev['starts_at'], 0, 10)))->format('%r%a'); if ($days <= 0) return 'Today'; if ($days === 1) return 'Tomorrow'; return $days <= 14 ? 'In ' . $days . ' days' : ''; } /** Short status for event cards: [label, tone] or null. */ function event_state_label(array $offer): ?array { if (!$offer) return null; $states = array_column($offer, 'state'); if (!in_array('on_sale', $states, true)) { return in_array('upcoming', $states, true) ? ['Coming soon', 'blue'] : [setting('label_sold_out'), 'red']; } $left = array_sum(array_map(fn($c) => $c['state'] === 'on_sale' ? $c['available'] : 0, $offer)); if ($left < setting_int('low_stock_threshold', 0, 100000)) return [fill_vars(setting('label_low_stock'), ['left' => $left]), 'amber']; foreach ($offer as $c) if ($c['state'] === 'on_sale' && $c['requires_approval'] && count($offer) === 1) return ['Approval required', 'blue']; return null; } /** Everything the event page template needs, or null when the event does not exist or is not public. */ function event_page_data(string $slug): ?array { $id = qv('SELECT id FROM {events} WHERE slug = ?', [$slug]); $ev = $id ? event_with_venue((int)$id) : null; $preview = $ev && $ev['status'] !== 'published' && can('events'); if (!$ev || ($ev['status'] !== 'published' && !$preview)) return null; $offer = event_offer($ev); $hasAutoDiscount = (bool)apply_filters('gatecraft_event_has_auto_discount', false, $ev); $hasCodes = (bool)qv('SELECT 1 FROM {discounts} WHERE code IS NOT NULL AND is_active = 1 AND (event_id IS NULL OR event_id = ?) AND (ends_at IS NULL OR ends_at > ?) LIMIT 1', [$ev['id'], now()]); return [ 'title' => $ev['title'], 'ev' => $ev, 'offer' => $offer, 'preview' => $preview, 'announcements' => live_announcements((int)$ev['id']), 'sponsors' => q('SELECT * FROM {event_sponsors} WHERE event_id = ? AND is_active = 1 ORDER BY sort_order, id', [(int)$ev['id']])->fetchAll(), 'closed' => $ev['starts_at'] <= now() || $ev['status'] === 'cancelled', 'has_codes' => $hasCodes, 'has_auto' => $hasAutoDiscount, 'user' => current_user(), 'description' => $ev['summary'] ?: rich_excerpt($ev['description'], 160), 'og_image' => upload_url($ev['cover_image']), ]; } function h_event(string $slug): void { $data = event_page_data($slug) ?? abort(404, 'This event could not be found.'); language_context($data['ev']['language'] ?? null, true); render('public/event', $data); } /** Public events can be booked by id; unlisted ones only from a page that knows their address. */ function event_reachable(array $ev): bool { return ($ev['visibility'] ?? 'public') === 'public' || hash_equals((string)$ev['slug'], (string)post('event_slug')); } function h_quote(): void { rate_limit_or_abort('quote', 120, 600); $code = post('code'); if ($code !== '') rate_limit_or_abort('quote-code', setting_int('code_tries', 5, 200), 600); $ev = q1("SELECT * FROM {events} WHERE id = ? AND status = 'published'", [post_int('event_id')]); if (!$ev || !event_reachable($ev)) json_out(['ok' => false, 'error' => 'Event not available.'], 404); language_context($ev['language'] ?? null, true); $qty = post_list('qty'); // Per-email code limits are only checked at checkout, so this endpoint cannot be used to learn who used a code. $email = current_user()['email'] ?? ''; try { $qt = quote_order($ev, $qty, $code, $email); } catch (UserError $ex) { json_out(['ok' => false, 'error' => error_text($ex)]); } json_out([ 'ok' => true, 'count' => $qt['count'], 'subtotal' => money($qt['subtotal']), 'discount' => $qt['discount_amount'] > 0 ? '-' . money($qt['discount_amount']) : null, 'discount_name' => $qt['discount']['name'] ?? null, 'total' => $qt['total'] > 0 ? money($qt['total']) : 'Free', 'code_error' => $qt['code_error'], 'requires_approval' => $qt['requires_approval'], 'total_minor' => $qt['total'], 'tax' => $qt['tax']['amount'] > 0 ? ($qt['tax']['mode'] === 'exclusive' ? '+' : '') . money($qt['tax']['amount']) : null, 'tax_label' => tax_line_label($qt['tax']['mode'], $qt['tax']['rate'], $qt['tax']['label']), ]); } function h_checkout(): void { rate_limit_or_abort('checkout', 20, 600); rate_limit_or_abort('checkout-hour', 40, 3600); $user = current_user(); $eventId = post_int('event_id'); $evRow = q1("SELECT id, slug, language, visibility, status, bot_check FROM {events} WHERE id = ?", [$eventId]); // An event that is not listed is only bought from its own page: its address is not revealed to someone counting ids. if ($evRow && ($evRow['status'] !== 'published' || !event_reachable($evRow))) $evRow = null; if (!$evRow) { flash('error', 'This event is not available.'); redirect('/'); } $slug = (string)$evRow['slug']; language_context($evRow['language'] ?: null, true); $back = '/e/' . $slug; if (!$user && setting('guest_checkout') !== '1') { flash('info', 'Please sign in to buy tickets.'); redirect(url('/login', ['next' => $back])); } set_back($back); spam_check(); do_action('gatecraft_checkout_verify', $evRow); // add-ons may add checks, such as a person check $consent = setting('contact_consent'); if ($consent === 'required' && !post_bool('contact_ok')) fail('Please agree to hear from us about events to complete your booking.'); sess_set('checkout_form', ['name' => post('name'), 'email' => post('email'), 'phone' => post('phone'), 'company' => post('company'), 'tax_id' => post('tax_id')]); $order = create_order($eventId, post_list('qty'), ['name' => post('name') ?: ($user['name'] ?? ''), 'email' => post('email') ?: ($user['email'] ?? ''), 'phone' => post('phone'), 'company' => post('company'), 'tax_id' => post('tax_id'), 'address' => post_text('address')], post_list('answers'), post('code'), $user, post('pay_with'), (bool)post_bool('agree')); sess_del('checkout_form'); // Someone typing an address into the form is not proof it is theirs: only a signed-in buyer booking with their own // address can re-subscribe or update an existing contact. A new address is added as before. $own = $user && strtolower((string)$user['email']) === strtolower((string)$order['buyer_email']); if ($consent !== 'off' && post_bool('contact_ok')) contact_upsert($order['buyer_email'], $order['buyer_name'], $order['language'] ?? current_language(), 'checkout', '', $own); sess_push('recent_orders', $order['public_token']); if ($order['status'] === 'pending_payment' && $order['payment_method'] === 'stripe') { try { redirect(stripe_checkout_url($order)); } catch (RuntimeException $ex) { if ($ex instanceof UserError) throw $ex; flash('error', 'We could not start the card payment. Your tickets are reserved, please try paying again.'); } } redirect('/order/' . $order['public_token']); } function load_order_or_404(string $token): array { $o = get_order_by_token($token); if (!$o) abort(404, 'Booking not found.'); language_context($o['language'] ?: qv('SELECT language FROM {events} WHERE id = ?', [$o['event_id']]), true); if (in_array($o['status'], ['pending_payment', 'awaiting_approval'], true) && $o['expires_at'] <= now()) { expire_orders(); $o = get_order((int)$o['id']); } return $o; } /** The link in the email that confirms a free booking: issues the tickets. */ /** * The link in the "confirm your email" message. Opening it changes nothing (mail scanners open links too): it shows a * page with a Confirm button, which posts to h_order_confirm_post() with the form's nonce. */ function h_order_confirm(string $token): void { $o = load_order_or_404($token); $back = '/order/' . $o['public_token']; if ($o['status'] === 'paid') redirect($back); $code = get_str('c'); $valid = $code !== '' && hash_equals(free_confirm_code($o), $code); header('Referrer-Policy: no-referrer'); render('public/confirm_email', ['title' => 'Confirm your booking', 'o' => $o, 'c' => $valid ? $code : '', 'valid' => $valid && awaiting_email_confirm($o), 'noindex' => true]); } /** Confirm a free booking: the POST from the confirmation page (nonce checked in dispatch()) with the signed code from the email. */ function h_order_confirm_post(string $token): void { rate_limit_or_abort('free-confirm', 30, 600); $o = load_order_or_404($token); $back = '/order/' . $o['public_token']; if (!hash_equals(free_confirm_code($o), post('c'))) { flash('error', 'This confirmation link is not valid. Use the newest email we sent.'); redirect($back); } if ($o['status'] === 'paid') redirect($back); // An expired link has already released its places (load_order_or_404 expires it), so it cannot be confirmed any more. if (!awaiting_email_confirm($o)) { flash('error', 'This link has expired and the places went back on sale. Book again from the event page if tickets are left.'); redirect($back); } mark_paid((int)$o['id'], 'free'); audit('order.email_confirmed', 'order', (int)$o['id']); flash('success', 'Thank you. Your email is confirmed and your tickets are below. We have emailed them too.'); redirect($back); } function h_order_confirm_resend(string $token): void { $o = load_order_or_404($token); $back = '/order/' . $o['public_token']; set_back($back); if (!awaiting_email_confirm($o)) redirect($back); if (!rate_limit('free-confirm-resend', (string)$o['id'], 3, 3600)) fail('We have sent this email several times already. Check your spam folder, or wait a little.'); email_order((int)$o['id'], 'confirm_email'); flash('success', 'Sent again to ' . $o['buyer_email'] . '.'); redirect($back); } function h_order(string $token): void { $o = load_order_or_404($token); header('Referrer-Policy: no-referrer'); header('X-Robots-Tag: noindex'); $tickets = $o['status'] === 'paid' ? order_tickets((int)$o['id']) : []; render('public/order', [ 'title' => 'Booking ' . $o['reference'], 'o' => $o, 'announcements' => live_announcements((int)$o['event_id']), 'items' => order_items((int)$o['id']), 'tickets' => $tickets, 'justPaid' => get_str('paid') === '1', 'noindex' => true, ]); } /** Older "Pay" links in emails: opening them changes nothing, they lead to the booking page and its Pay button. */ function h_order_pay_link(string $token): void { $o = load_order_or_404($token); redirect('/order/' . $o['public_token']); } /** Start the card payment (or show the konbini slip): the POST from the Pay button on the booking page. */ function h_order_pay(string $token): void { rate_limit_or_abort('pay', 20, 600); $o = load_order_or_404($token); if ($o['status'] !== 'pending_payment') redirect('/order/' . $token); if ($o['payment_method'] !== 'stripe') redirect('/order/' . $token); if (($o['pay_option'] ?? '') === 'Konbini') { $slip = (string)($o['pay_instructions'] ?? ''); if (str_starts_with($slip, 'https://')) redirect($slip); flash('info', 'We could not open the payment slip here. Use the link in the email we sent you, or contact the organiser.'); redirect('/order/' . $token); } try { redirect(stripe_checkout_url($o)); } catch (UserError $ex) { flash('error', $ex->getMessage()); } catch (RuntimeException $ex) { flash('error', 'We could not start the card payment. Please try again in a moment.'); } redirect('/order/' . $token); } function h_order_ics(string $token): void { $o = load_order_or_404($token); $ics = event_ics((int)$o['event_id']); if ($ics === null) abort(404, 'We could not find that event.'); // A calendar file, never HTML: fixed headers (no request data in them) and nosniff, so a browser cannot treat it as a page. header('Content-Type: text/calendar; charset=utf-8'); header('Content-Disposition: attachment; filename="event.ics"'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); echo $ics; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/calendar file, not HTML; every value is escaped for iCalendar by ics_text() in mailer.php exit; } /** A clean, print-ready sheet of the valid tickets: the page the buyer saves as a PDF. */ function h_order_tickets(string $token): void { $o = load_order_or_404($token); if ($o['status'] !== 'paid') redirect('/order/' . $token); $tickets = array_values(array_filter(order_tickets((int)$o['id']), fn($t) => $t['status'] === 'valid')); if (!$tickets) redirect('/order/' . $token); header('Content-Type: text/html; charset=utf-8'); header('Referrer-Policy: no-referrer'); header('X-Robots-Tag: noindex, nofollow'); header('Cache-Control: no-store'); $ev = event_with_venue((int)$o['event_id']); stream_template('public/ticket_sheet', ['o' => $o, 'ev' => $ev, 'tickets' => $tickets, 'auto' => get_str('print') === '1']); } function h_login(): void { $next = safe_next(get_path('next'), '/'); if (current_user()) redirect($next === '/' && is_staff() ? '/admin' : $next); render('public/login', ['title' => 'Sign in', 'next' => $next, 'wpLogin' => wp_login_url(map_url($next))]); } function twofa_state(): array { $s = (sess_get('pending_2fa') ?? sess_get('enroll_2fa')); if (!$s || time() - $s['at'] > 600) { sess_del('pending_2fa', 'enroll_2fa'); flash('error', 'Your sign-in timed out. Please sign in again.'); wp_safe_redirect(wp_login_url()); exit; } return [$s, sess_has('enroll_2fa')]; } function h_twofa(): void { [$s, $enroll] = twofa_state(); $email = strtolower((string)get_userdata((int)$s['uid'])->user_email); render('public/twofa', ['title' => 'Two-step verification', 'enroll' => $enroll, 'secret' => $enroll ? $s['secret'] : null, 'qr' => $enroll ? qr_svg(totp_uri($s['secret'], $email)) : null, 'email' => $email]); } function h_twofa_post(): void { [$s, $enroll] = twofa_state(); rate_limit_or_abort('2fa', 8, 900, (string)$s['uid']); $code = post('code'); if ($enroll) { $step = totp_match($s['secret'], $code, null); if ($step === null) { flash('error', 'That code did not match. Check the time on your phone and try again.'); redirect('/login/2fa'); } account_touch((int)$s['uid']); q('UPDATE {accounts} SET totp_secret_enc = ?, totp_enabled = 1, totp_last_step = ? WHERE user_id = ?', [encrypt_secret($s['secret']), $step, $s['uid']]); audit('2fa.enrolled', 'user', $s['uid']); gatecraft_notify_security_change((int)$s['uid'], 'An authenticator app was just set up for your account'); } elseif (!totp_verify_user($s['uid'], $code)) { audit('login.2fa_failed', 'user', $s['uid']); flash('error', 'That code did not match. Try the newest code in your app.'); redirect('/login/2fa'); } $user = get_userdata((int)$s['uid']); if (!$user || account_row((int)$s['uid'])['status'] !== 'active') { sess_del('pending_2fa', 'enroll_2fa'); fail('This account is disabled.'); } // Read the form before signing in: the nonce of this form belongs to the visitor who was not signed in yet. $trustBrowser = post_bool('remember_browser'); login_user((int)$s['uid'], ($s['via'] ?? 'password') . '+totp', !empty($s['remember'])); if ($trustBrowser) trust_remember((int)$s['uid']); wp_safe_redirect($s['next'] ?: admin_url()); exit; } function h_logout(): void { logout_user(); redirect('/'); } function h_account(): void { $u = require_login(); $orders = q('SELECT o.*, e.title AS event_title, e.starts_at, e.slug, e.cover_image, v.name AS venue_name, v.city AS venue_city, (SELECT COUNT(*) FROM {tickets} t WHERE t.order_id = o.id AND t.status = \'valid\') AS ticket_count FROM {orders} o JOIN {events} e ON e.id = o.event_id LEFT JOIN {venues} v ON v.id = e.venue_id WHERE o.user_id = ? ORDER BY e.starts_at DESC', [$u['id']])->fetchAll(); $upcoming = array_values(array_filter($orders, fn($o) => $o['starts_at'] > now())); $past = array_values(array_filter($orders, fn($o) => $o['starts_at'] <= now())); render('public/account', ['title' => 'My tickets', 'u' => $u, 'upcoming' => $upcoming, 'past' => $past]); } function h_security(): void { $u = require_login(); if (!is_staff($u)) redirect('/account'); render('public/security', ['title' => 'Account security', 'u' => $u, 'profile' => admin_url('profile.php')]); } /** * Stripe webhook: a server-to-server call, so no WordPress nonce or session applies. Each request is authenticated by * its Stripe-Signature header instead: an HMAC-SHA256 of the timestamp and the raw body with the webhook signing secret, * compared in constant time and refused when older than five minutes (stripe_signature_valid() in card-payments.php). * Nothing in the payload is decoded or used before that check passes. */ function h_stripe_webhook(): void { header('Content-Type: text/plain; charset=utf-8'); header('X-Content-Type-Options: nosniff'); $secret = (string)cfg('stripe_webhook_secret'); $signature = sanitize_text_field(server('HTTP_STRIPE_SIGNATURE')); $payload = (string)file_get_contents('php://input', false, null, 0, 1024 * 1024); // 1. Authenticate: without a configured secret and a valid signature the request stops here. if ($secret === '' || $signature === '' || !stripe_signature_valid($payload, $signature, $secret)) { status_header(400); echo esc_html('invalid'); exit; } // 2. Only then handle the event. $code = stripe_handle_webhook($payload, $signature); status_header($code); echo esc_html($code === 200 ? 'ok' : 'invalid'); exit; } function render_policy(string $title, string $key, bool $withBusiness = false): void { $text = setting($key); if ($text === '' && !($withBusiness && setting('business_details') !== '')) abort(404, 'This page has not been published yet.'); render('public/policy', ['title' => $title, 'text' => $text, 'business' => $withBusiness ? setting('business_details') : '']); } function h_terms(): void { render_policy('Terms of sale', 'terms_text', true); } function h_refunds(): void { render_policy('Refund policy', 'refund_text'); } function h_privacy(): void { render_policy('Privacy policy', 'privacy_text'); } /** Announcements that are live right now, most urgent first, then newest. */ function live_announcements(int $eventId): array { $n = now(); return q("SELECT * FROM {event_announcements} WHERE event_id = ? AND is_active = 1 AND (show_from IS NULL OR show_from <= ?) AND (show_until IS NULL OR show_until > ?) ORDER BY FIELD(level, 'urgent', 'important', 'info'), COALESCE(show_from, created_at) DESC, id DESC", [$eventId, $n, $n])->fetchAll(); }