Plugin Directory

Changeset 3799773


Ignore:
Timestamp:
11/13/2026 06:38:04 AM (37 hours ago)
Author:
gatecraftjp
Message:

2026.1.3.12: security and reliability improvements

Location:
gatecraft
Files:
24 edited
1 copied

Legend:

Unmodified
Added
Removed
  • gatecraft/tags/2026.1.3.12/gatecraft.php

    r3797341 r3799773  
    33 * Plugin Name:       Gatecraft
    44 * Description:       Sell tickets for your own events: priced categories, card and offline payments, QR tickets by email, and a phone camera door scanner.
    5  * Version:           2026.1.3.11
     5 * Version:           2026.1.3.12
    66 * Requires at least: 6.7
    77 * Requires PHP:      8.1
    … …  
    2626}
    2727
    28 define('GATECRAFT_VERSION', '2026.1.3.11');
     28define('GATECRAFT_VERSION', '2026.1.3.12');
    2929define('GATECRAFT_DB_VERSION', 18);
    3030define('GATECRAFT_FILE', __FILE__);
  • gatecraft/tags/2026.1.3.12/includes/eventclone.php

    r3795649 r3799773  
    4545    $row['title'] = mb_substr((string)($o['title'] ?? $src['title']), 0, 190);
    4646    $row['slug'] = eventpack_unique_slug((string)($o['slug'] ?? $src['slug']));
     47    // A copy of an unlisted event gets its own private address part, never the original's.
     48    if (($row['visibility'] ?? 'public') === 'unlisted') $row['slug'] = event_private_slug(event_slug_base(event_slug_base($row['slug'])));
    4749    $row['status'] = in_array($o['status'] ?? 'draft', ['draft', 'published'], true) ? ($o['status'] ?? 'draft') : 'draft';
    4850    $row['starts_at'] = ldate('Y-m-d H:i:s', lstrtotime((string)$o['starts_at']));
  • gatecraft/tags/2026.1.3.12/includes/eventpack.php

    r3795649 r3799773  
    257257        if (isset($e['description'])) $e['description'] = rich_clean((string)$e['description']);
    258258        $e['slug'] = eventpack_unique_slug((string)($e['slug'] ?? $e['title']));
     259        if (($e['visibility'] ?? 'public') === 'unlisted') $e['slug'] = event_private_slug(event_slug_base($e['slug']));
    259260        if ($e['slug'] !== ($pkg['event']['slug'] ?? '')) $notes[] = 'The page address was already in use, so this event is at "' . $e['slug'] . '".';
    260261        $e['status'] = 'draft'; $e['venue_id'] = $venueId; $e['cover_image'] = eventpack_save_image($pkg, $e['cover_image'] ?? null);
  • gatecraft/tags/2026.1.3.12/includes/helpers.php

    r3797341 r3799773  
    687687}
    688688
     689/**
     690 * Unlisted events are reached only by their address, so the address must not be guessable from the title.
     691 * event_private_slug() ends it with 12 random hex characters (48 bits); event_slug_base() removes that part again,
     692 * so a copy of an unlisted event never carries the original's private part.
     693 */
     694function event_slug_base(string $slug): string
     695{
     696    return (string)preg_replace('/-[a-f0-9]{12}$/', '', $slug);
     697}
     698
     699function event_private_slug(string $slug): string
     700{
     701    if (preg_match('/-[a-f0-9]{12}$/', $slug)) return $slug;
     702    return rtrim(substr($slug, 0, 100), '-') . '-' . bin2hex(random_bytes(6));
     703}
     704
    689705/** Crockford base32 (no I, L, O, U): readable, unambiguous codes. */
    690706function random_code(int $len): string
  • gatecraft/tags/2026.1.3.12/includes/http/admin.php

    r3795460 r3799773  
    227227    $title = str_in('title', 190, true, 'Title');
    228228    $slug = slugify(str_in('slug', 120) ?: $title);
    229     if (qv('SELECT 1 FROM {events} WHERE slug = ? AND id <> ?', [$slug, $id])) $slug = substr($slug, 0, 110) . '-' . strtolower(random_code(4));
     229    // An unlisted event's address gets a private part, so it cannot be guessed from the title.
     230    if (post('visibility') === 'unlisted') $slug = event_private_slug($slug);
     231    if (qv('SELECT 1 FROM {events} WHERE slug = ? AND id <> ?', [$slug, $id])) $slug = post('visibility') === 'unlisted' ? event_private_slug(event_slug_base($slug)) : substr($slug, 0, 110) . '-' . strtolower(random_code(4));
    230232    $start = dt_in(post('starts_at'));
    231233    if (!$start) fail('Start date and time are required.');
  • gatecraft/tags/2026.1.3.12/includes/http/public.php

    r3795460 r3799773  
    125125    $data = event_page_data($slug) ?? abort(404, 'This event could not be found.');
    126126    language_context($data['ev']['language'] ?? null, true);
     127    if (($data['ev']['visibility'] ?? 'public') === 'unlisted') {
     128        // Unlisted: keep the page out of search engines, and do not pass its private address on to other sites.
     129        header('X-Robots-Tag: noindex, nofollow');
     130        header('Referrer-Policy: no-referrer');
     131        add_filter('wp_robots', fn(array $r) => ['noindex' => true, 'nofollow' => true] + $r);
     132        $data['noindex'] = true;
     133    }
    127134    render('public/event', $data);
    128135}
  • gatecraft/tags/2026.1.3.12/includes/orders.php

    r3795460 r3799773  
    102102        if ($event['starts_at'] <= now()) fail('Ticket sales for this event have closed.');
    103103
    104         // Limit open (unpaid or unapproved) orders per email, so one person cannot hold the whole event.
    105         $open = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND buyer_email = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?", [$eventId, $buyer['email'], now()]);
     104        // Limit open (unpaid or unapproved) orders per email, so one person cannot hold the whole event. Only bookings
     105        // from this visitor count (same connection, or same signed-in account): bookings someone else made with this
     106        // email cannot lock its owner out, and the message reveals nothing about bookings made elsewhere.
     107        $open = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND buyer_email = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?
     108                         AND (ip_hash = ? OR (user_id IS NOT NULL AND user_id = ?))", [$eventId, $buyer['email'], now(), $ipHash, (int)($user['id'] ?? 0)]);
    106109        if ($open >= 2) fail('You already have open bookings for this event. Complete or cancel them first, or wait for them to expire.');
    107110        // And per network address (hashed, never stored as an address), so changing the email does not let one person hold every seat.
  • gatecraft/tags/2026.1.3.12/includes/router.php

    r3795460 r3799773  
    4848        // 2. Routes limited to staff check the capability with current_user_can() (require_perm()).
    4949        if ($perm !== null) require_perm($perm);
    50         $args = array_map(fn($v) => ctype_digit($v) ? (int)$v : $v, array_slice($mm, 1));
     50        // Only {id} parts become numbers. A slug or token can be all digits ("2027") and must stay text.
     51        preg_match_all('/\{([a-z]+)\}/', $pattern, $names);
     52        $args = [];
     53        foreach (array_slice($mm, 1) as $i => $v) $args[] = ($names[1][$i] ?? '') === 'id' ? (int)$v : $v;
    5154        try {
    5255            $fn = str_contains($handler, '\\') ? $handler : __NAMESPACE__ . '\\' . $handler;
  • gatecraft/tags/2026.1.3.12/includes/security.php

    r3795460 r3799773  
    162162    header('Cross-Origin-Opener-Policy: same-origin');
    163163    header('Cross-Origin-Resource-Policy: same-origin');
    164     if (is_https()) header('Strict-Transport-Security: max-age=63072000; includeSubDomains');
     164    // This site only: a plugin must not set the HTTPS rule for the owner's other subdomains.
     165    if (is_https()) header('Strict-Transport-Security: max-age=31536000');
    165166    header_remove('X-Powered-By');
    166167}
  • gatecraft/tags/2026.1.3.12/languages/ja.php

    r3795651 r3799773  
    26762676        'Unlimited' => '無制限',
    26772677        'Unlisted, link only' => '非公開(リンクを知っている人のみ)',
     2678        'Unlisted events get a private code at the end of their address, so only people with the link can find them. Share the address shown after you save.' => '非公開のイベントでは、アドレスの末尾に推測できないコードが付き、リンクを知っている人だけが開けます。保存後に表示されるアドレスを共有してください。',
    26782679        'Unmatched card payment for booking' => '予約と一致しないクレジットカード支払い:',
    26792680        'Unread' => '未読',
  • gatecraft/tags/2026.1.3.12/readme.txt

    r3797341 r3799773  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 2026.1.3.11
     7Stable tag: 2026.1.3.12
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    129129== Changelog ==
    130130
     131= 2026.1.3.12 =
     132* Changed: Unlisted events get a private code at the end of their address, so only people with the link can find them. They are also kept out of search engines.
     133* Fixed: An event whose address was only numbers could not be opened.
     134* Improved: Security and reliability improvements.
     135
    131136= 2026.1.3.11 =
    132137* Improved: Gatecraft now runs in WordPress Playground, so you can try it in your browser with Live Preview.
  • gatecraft/tags/2026.1.3.12/templates/admin/event_form.php

    r3795460 r3799773  
    3838    <div class="card stack">
    3939      <?php echo wp_kses( field('select', 'status', 'Status', $ev['status'], ['options' => ['draft' => 'Draft (hidden)', 'published' => 'Published', 'archived' => 'Archived'] + ($ev['status'] === 'cancelled' ? ['cancelled' => 'Cancelled'] : [])]), allowed_html() ); ?>
    40       <?php echo wp_kses( field('select', 'visibility', 'Visibility', $ev['visibility'], ['options' => ['public' => 'Public, listed on the home page', 'unlisted' => 'Unlisted, link only']]), allowed_html() ); ?>
     40      <?php echo wp_kses( field('select', 'visibility', 'Visibility', $ev['visibility'], ['options' => ['public' => 'Public, listed on the home page', 'unlisted' => 'Unlisted, link only'], 'help' => 'Unlisted events get a private code at the end of their address, so only people with the link can find them. Share the address shown after you save.']), allowed_html() ); ?>
    4141      <?php slot('event_form_visibility', ['ev' => $ev]); ?>
    4242    </div>
  • gatecraft/trunk/gatecraft.php

    r3797341 r3799773  
    33 * Plugin Name:       Gatecraft
    44 * Description:       Sell tickets for your own events: priced categories, card and offline payments, QR tickets by email, and a phone camera door scanner.
    5  * Version:           2026.1.3.11
     5 * Version:           2026.1.3.12
    66 * Requires at least: 6.7
    77 * Requires PHP:      8.1
    … …  
    2626}
    2727
    28 define('GATECRAFT_VERSION', '2026.1.3.11');
     28define('GATECRAFT_VERSION', '2026.1.3.12');
    2929define('GATECRAFT_DB_VERSION', 18);
    3030define('GATECRAFT_FILE', __FILE__);
  • gatecraft/trunk/includes/eventclone.php

    r3795649 r3799773  
    4545    $row['title'] = mb_substr((string)($o['title'] ?? $src['title']), 0, 190);
    4646    $row['slug'] = eventpack_unique_slug((string)($o['slug'] ?? $src['slug']));
     47    // A copy of an unlisted event gets its own private address part, never the original's.
     48    if (($row['visibility'] ?? 'public') === 'unlisted') $row['slug'] = event_private_slug(event_slug_base(event_slug_base($row['slug'])));
    4749    $row['status'] = in_array($o['status'] ?? 'draft', ['draft', 'published'], true) ? ($o['status'] ?? 'draft') : 'draft';
    4850    $row['starts_at'] = ldate('Y-m-d H:i:s', lstrtotime((string)$o['starts_at']));
  • gatecraft/trunk/includes/eventpack.php

    r3795649 r3799773  
    257257        if (isset($e['description'])) $e['description'] = rich_clean((string)$e['description']);
    258258        $e['slug'] = eventpack_unique_slug((string)($e['slug'] ?? $e['title']));
     259        if (($e['visibility'] ?? 'public') === 'unlisted') $e['slug'] = event_private_slug(event_slug_base($e['slug']));
    259260        if ($e['slug'] !== ($pkg['event']['slug'] ?? '')) $notes[] = 'The page address was already in use, so this event is at "' . $e['slug'] . '".';
    260261        $e['status'] = 'draft'; $e['venue_id'] = $venueId; $e['cover_image'] = eventpack_save_image($pkg, $e['cover_image'] ?? null);
  • gatecraft/trunk/includes/helpers.php

    r3797341 r3799773  
    687687}
    688688
     689/**
     690 * Unlisted events are reached only by their address, so the address must not be guessable from the title.
     691 * event_private_slug() ends it with 12 random hex characters (48 bits); event_slug_base() removes that part again,
     692 * so a copy of an unlisted event never carries the original's private part.
     693 */
     694function event_slug_base(string $slug): string
     695{
     696    return (string)preg_replace('/-[a-f0-9]{12}$/', '', $slug);
     697}
     698
     699function event_private_slug(string $slug): string
     700{
     701    if (preg_match('/-[a-f0-9]{12}$/', $slug)) return $slug;
     702    return rtrim(substr($slug, 0, 100), '-') . '-' . bin2hex(random_bytes(6));
     703}
     704
    689705/** Crockford base32 (no I, L, O, U): readable, unambiguous codes. */
    690706function random_code(int $len): string
  • gatecraft/trunk/includes/http/admin.php

    r3795460 r3799773  
    227227    $title = str_in('title', 190, true, 'Title');
    228228    $slug = slugify(str_in('slug', 120) ?: $title);
    229     if (qv('SELECT 1 FROM {events} WHERE slug = ? AND id <> ?', [$slug, $id])) $slug = substr($slug, 0, 110) . '-' . strtolower(random_code(4));
     229    // An unlisted event's address gets a private part, so it cannot be guessed from the title.
     230    if (post('visibility') === 'unlisted') $slug = event_private_slug($slug);
     231    if (qv('SELECT 1 FROM {events} WHERE slug = ? AND id <> ?', [$slug, $id])) $slug = post('visibility') === 'unlisted' ? event_private_slug(event_slug_base($slug)) : substr($slug, 0, 110) . '-' . strtolower(random_code(4));
    230232    $start = dt_in(post('starts_at'));
    231233    if (!$start) fail('Start date and time are required.');
  • gatecraft/trunk/includes/http/public.php

    r3795460 r3799773  
    125125    $data = event_page_data($slug) ?? abort(404, 'This event could not be found.');
    126126    language_context($data['ev']['language'] ?? null, true);
     127    if (($data['ev']['visibility'] ?? 'public') === 'unlisted') {
     128        // Unlisted: keep the page out of search engines, and do not pass its private address on to other sites.
     129        header('X-Robots-Tag: noindex, nofollow');
     130        header('Referrer-Policy: no-referrer');
     131        add_filter('wp_robots', fn(array $r) => ['noindex' => true, 'nofollow' => true] + $r);
     132        $data['noindex'] = true;
     133    }
    127134    render('public/event', $data);
    128135}
  • gatecraft/trunk/includes/orders.php

    r3795460 r3799773  
    102102        if ($event['starts_at'] <= now()) fail('Ticket sales for this event have closed.');
    103103
    104         // Limit open (unpaid or unapproved) orders per email, so one person cannot hold the whole event.
    105         $open = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND buyer_email = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?", [$eventId, $buyer['email'], now()]);
     104        // Limit open (unpaid or unapproved) orders per email, so one person cannot hold the whole event. Only bookings
     105        // from this visitor count (same connection, or same signed-in account): bookings someone else made with this
     106        // email cannot lock its owner out, and the message reveals nothing about bookings made elsewhere.
     107        $open = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND buyer_email = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?
     108                         AND (ip_hash = ? OR (user_id IS NOT NULL AND user_id = ?))", [$eventId, $buyer['email'], now(), $ipHash, (int)($user['id'] ?? 0)]);
    106109        if ($open >= 2) fail('You already have open bookings for this event. Complete or cancel them first, or wait for them to expire.');
    107110        // And per network address (hashed, never stored as an address), so changing the email does not let one person hold every seat.
  • gatecraft/trunk/includes/router.php

    r3795460 r3799773  
    4848        // 2. Routes limited to staff check the capability with current_user_can() (require_perm()).
    4949        if ($perm !== null) require_perm($perm);
    50         $args = array_map(fn($v) => ctype_digit($v) ? (int)$v : $v, array_slice($mm, 1));
     50        // Only {id} parts become numbers. A slug or token can be all digits ("2027") and must stay text.
     51        preg_match_all('/\{([a-z]+)\}/', $pattern, $names);
     52        $args = [];
     53        foreach (array_slice($mm, 1) as $i => $v) $args[] = ($names[1][$i] ?? '') === 'id' ? (int)$v : $v;
    5154        try {
    5255            $fn = str_contains($handler, '\\') ? $handler : __NAMESPACE__ . '\\' . $handler;
  • gatecraft/trunk/includes/security.php

    r3795460 r3799773  
    162162    header('Cross-Origin-Opener-Policy: same-origin');
    163163    header('Cross-Origin-Resource-Policy: same-origin');
    164     if (is_https()) header('Strict-Transport-Security: max-age=63072000; includeSubDomains');
     164    // This site only: a plugin must not set the HTTPS rule for the owner's other subdomains.
     165    if (is_https()) header('Strict-Transport-Security: max-age=31536000');
    165166    header_remove('X-Powered-By');
    166167}
  • gatecraft/trunk/languages/ja.php

    r3795651 r3799773  
    26762676        'Unlimited' => '無制限',
    26772677        'Unlisted, link only' => '非公開(リンクを知っている人のみ)',
     2678        'Unlisted events get a private code at the end of their address, so only people with the link can find them. Share the address shown after you save.' => '非公開のイベントでは、アドレスの末尾に推測できないコードが付き、リンクを知っている人だけが開けます。保存後に表示されるアドレスを共有してください。',
    26782679        'Unmatched card payment for booking' => '予約と一致しないクレジットカード支払い:',
    26792680        'Unread' => '未読',
  • gatecraft/trunk/readme.txt

    r3797341 r3799773  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 2026.1.3.11
     7Stable tag: 2026.1.3.12
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    129129== Changelog ==
    130130
     131= 2026.1.3.12 =
     132* Changed: Unlisted events get a private code at the end of their address, so only people with the link can find them. They are also kept out of search engines.
     133* Fixed: An event whose address was only numbers could not be opened.
     134* Improved: Security and reliability improvements.
     135
    131136= 2026.1.3.11 =
    132137* Improved: Gatecraft now runs in WordPress Playground, so you can try it in your browser with Live Preview.
  • gatecraft/trunk/templates/admin/event_form.php

    r3795460 r3799773  
    3838    <div class="card stack">
    3939      <?php echo wp_kses( field('select', 'status', 'Status', $ev['status'], ['options' => ['draft' => 'Draft (hidden)', 'published' => 'Published', 'archived' => 'Archived'] + ($ev['status'] === 'cancelled' ? ['cancelled' => 'Cancelled'] : [])]), allowed_html() ); ?>
    40       <?php echo wp_kses( field('select', 'visibility', 'Visibility', $ev['visibility'], ['options' => ['public' => 'Public, listed on the home page', 'unlisted' => 'Unlisted, link only']]), allowed_html() ); ?>
     40      <?php echo wp_kses( field('select', 'visibility', 'Visibility', $ev['visibility'], ['options' => ['public' => 'Public, listed on the home page', 'unlisted' => 'Unlisted, link only'], 'help' => 'Unlisted events get a private code at the end of their address, so only people with the link can find them. Share the address shown after you save.']), allowed_html() ); ?>
    4141      <?php slot('event_form_visibility', ['ev' => $ev]); ?>
    4242    </div>
Note: See TracChangeset for help on using the changeset viewer.