fetchAll(); } catch (Throwable $e) { return $c[$table] = []; } $out = []; foreach ($rows as $r) $out[] = (string)($r['Field'] ?? array_values($r)[0]); return $c[$table] = $out; } function eventpack_has_table(string $table): bool { return eventpack_columns($table) !== []; } /** A row without database ids and the fields that only make sense on the system that wrote them. */ function eventpack_row(array $row, array $drop = []): array { // Links to other rows, and fields only this system sets (series, reminder and alert marks, invoice numbering), are never taken from a file. foreach (array_merge(['id', 'event_id', 'category_id', 'tier_id', 'venue_id', 'venue_section_id', 'order_id', 'discount_id', 'created_by', 'user_id', 'decided_by', 'checked_in_by', 'ip_hash', 'series_id', 'reminder_sent_at', 'capacity_alerted_at', 'invoice_number', 'invoiced_at', 'invoice_meta'], $drop) as $k) unset($row[$k]); return $row; } /** Insert a row, keeping only columns this system has. Returns [new id, dropped column names]. */ function eventpack_insert(string $table, array $row): array { $cols = eventpack_columns($table); $use = []; $dropped = []; foreach ($row as $k => $v) { if (!preg_match('/^[a-z_]+$/', (string)$k)) continue; if (in_array($k, $cols, true)) $use[$k] = is_array($v) ? wp_json_encode($v, JSON_UNESCAPED_UNICODE) : $v; else $dropped[] = $k; } if (!$use) return [0, $dropped]; q('INSERT INTO ' . eventpack_tbl($table) . ' (' . implode(',', array_map(fn($k) => '`' . $k . '`', array_keys($use))) . ') VALUES (' . implode(',', array_fill(0, count($use), '?')) . ')', array_values($use)); return [(int)db_insert_id(), $dropped]; } function eventpack_image(array &$images, ?string $name): ?string { if (!$name) return null; $path = upload_path($name); if (!$path) return null; $bytes = (string)gatecraft_fs()->get_contents($path); if ($bytes === '') return null; foreach ($images as $k => $im) if ($im['sha'] === sha1($bytes)) return $k; $key = 'img' . (count($images) + 1); $images[$key] = ['mime' => str_ends_with($name, '.png') ? 'image/png' : 'image/jpeg', 'sha' => sha1($bytes), 'data' => base64_encode($bytes)]; return $key; } /** Build the package for one event. */ function eventpack_build(int $eventId, bool $withBookings): array { $ev = q1('SELECT * FROM {events} WHERE id = ?', [$eventId]) ?? throw new \RuntimeException('Event not found.'); $images = []; $pkg = ['format' => EVENTPACK_FORMAT, 'version' => EVENTPACK_VERSION, 'exported_at' => gmdate('c'), 'source' => ['app' => defined('GATECRAFT_VERSION') ? 'gatecraft' : 'gatecraft-standalone', 'version' => defined('GATECRAFT_VERSION') ? GATECRAFT_VERSION : 'standalone'], 'includes_bookings' => $withBookings]; $row = eventpack_row($ev, ['reminder_sent_at', 'capacity_alerted_at']); $row['cover_image'] = eventpack_image($images, $ev['cover_image'] ?? null); $pkg['event'] = $row; $venue = !empty($ev['venue_id']) ? q1('SELECT * FROM {venues} WHERE id = ?', [$ev['venue_id']]) : null; $sections = []; if ($venue) { $pkg['venue'] = eventpack_row($venue); foreach (q('SELECT * FROM {venue_sections} WHERE venue_id = ? ORDER BY sort_order, id', [$venue['id']])->fetchAll() as $s) $sections[(int)$s['id']] = $s['name']; $pkg['venue']['sections'] = array_map(fn($s) => eventpack_row($s), q('SELECT * FROM {venue_sections} WHERE venue_id = ? ORDER BY sort_order, id', [$venue['id']])->fetchAll()); } $catRef = []; $tierRef = []; $pkg['categories'] = []; foreach (q('SELECT * FROM {ticket_categories} WHERE event_id = ? ORDER BY sort_order, id', [$eventId])->fetchAll() as $i => $c) { $ref = 'c' . ($i + 1); $catRef[(int)$c['id']] = $ref; $r = eventpack_row($c, ['venue_section_id']); $r['ref'] = $ref; $r['section'] = $sections[(int)($c['venue_section_id'] ?? 0)] ?? null; $r['tiers'] = []; foreach (q('SELECT * FROM {ticket_tiers} WHERE category_id = ? ORDER BY sort_order, id', [$c['id']])->fetchAll() as $j => $t) { $tr = eventpack_row($t); $tr['ref'] = $ref . 't' . ($j + 1); $tierRef[(int)$t['id']] = $tr['ref']; $r['tiers'][] = $tr; } $pkg['categories'][] = $r; } $pkg['questions'] = array_map(fn($q) => eventpack_row($q) + ['category' => $catRef[(int)($q['category_id'] ?? 0)] ?? null], eventpack_has_table('event_questions') ? q('SELECT * FROM {event_questions} WHERE event_id = ? ORDER BY sort_order, id', [$eventId])->fetchAll() : []); $discRef = []; $pkg['discounts'] = []; foreach (q('SELECT * FROM {discounts} WHERE event_id = ? ORDER BY id', [$eventId])->fetchAll() as $i => $d) { $discRef[(int)$d['id']] = $i; $pkg['discounts'][] = eventpack_row($d) + ['category' => $catRef[(int)($d['category_id'] ?? 0)] ?? null]; } if (eventpack_has_table('event_announcements')) $pkg['announcements'] = array_map(fn($a) => eventpack_row($a), q('SELECT * FROM {event_announcements} WHERE event_id = ? ORDER BY id', [$eventId])->fetchAll()); if (eventpack_has_table('event_sponsors')) { $pkg['sponsors'] = []; foreach (q('SELECT * FROM {event_sponsors} WHERE event_id = ? ORDER BY sort_order, id', [$eventId])->fetchAll() as $s) { $r = eventpack_row($s); $r['logo'] = eventpack_image($images, $s['logo'] ?? null); $r['image'] = eventpack_image($images, $s['image'] ?? null); $pkg['sponsors'][] = $r; } } if ($withBookings) { $pkg['orders'] = []; foreach (q('SELECT * FROM {orders} WHERE event_id = ? ORDER BY id', [$eventId])->fetchAll() as $o) { $r = eventpack_row($o, ['discount_id', 'stripe_session_id']); $r['discount'] = isset($discRef[(int)($o['discount_id'] ?? 0)]) ? $discRef[(int)$o['discount_id']] : null; $r['items'] = array_map(fn($it) => ['category' => $catRef[(int)$it['category_id']] ?? null, 'tier' => $tierRef[(int)$it['tier_id']] ?? null, 'qty' => (int)$it['qty'], 'unit_price' => (int)$it['unit_price']], q('SELECT * FROM {order_items} WHERE order_id = ? ORDER BY id', [$o['id']])->fetchAll()); $r['tickets'] = array_map(fn($t) => eventpack_row($t, ['category_id', 'tier_id']) + ['category' => $catRef[(int)$t['category_id']] ?? null, 'tier' => $tierRef[(int)$t['tier_id']] ?? null], q('SELECT * FROM {tickets} WHERE order_id = ? ORDER BY id', [$o['id']])->fetchAll()); $pkg['orders'][] = $r; } } $pkg['images'] = $images; foreach ($pkg['images'] as &$im) unset($im['sha']); unset($im); return $pkg; } /** Encode a package as the file contents. A password turns the whole payload into an encrypted envelope. */ /** JSON options for event files. */ const EVENTPACK_JSON = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT; function eventpack_encode(array $pkg, string $password = ''): string { return (string)wp_json_encode(eventpack_payload($pkg, $password), EVENTPACK_JSON | JSON_THROW_ON_ERROR); } /** The file's content as data: the package itself, or the encrypted envelope around it when a password is given. */ function eventpack_payload(array $pkg, string $password = ''): array { if ($password === '') return $pkg; $json = wp_json_encode($pkg, EVENTPACK_JSON | JSON_THROW_ON_ERROR); if (strlen($password) < 10) throw new \RuntimeException('Use a password of at least 10 characters.'); $salt = random_bytes(16); $iv = random_bytes(12); $iter = 200000; $key = hash_pbkdf2('sha256', $password, $salt, $iter, 32, true); $tag = ''; $enc = openssl_encrypt($json, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); if ($enc === false) throw new \RuntimeException('Encryption failed on this server.'); return ['format' => EVENTPACK_FORMAT, 'version' => EVENTPACK_VERSION, 'encrypted' => true, 'includes_bookings' => !empty($pkg['includes_bookings']), 'kdf' => 'pbkdf2-sha256', 'iterations' => $iter, 'cipher' => 'aes-256-gcm', 'salt' => base64_encode($salt), 'iv' => base64_encode($iv), 'tag' => base64_encode($tag), 'data' => base64_encode($enc)]; } /** Read file contents back into a package. Throws a message fit for the person importing. */ function eventpack_decode(string $raw, string $password = ''): array { if (strlen($raw) > EVENTPACK_MAX_BYTES) throw new \RuntimeException('That file is too large.'); $a = json_decode($raw, true); if (!is_array($a) || ($a['format'] ?? '') !== EVENTPACK_FORMAT) throw new \RuntimeException('This is not a Gatecraft event file.'); if ((int)($a['version'] ?? 0) > EVENTPACK_VERSION) throw new \RuntimeException('This file was made by a newer version. Update this system first.'); if (!empty($a['encrypted'])) { if ($password === '') throw new \RuntimeException('This file is encrypted. Enter its password.'); if (($a['cipher'] ?? '') !== 'aes-256-gcm' || ($a['kdf'] ?? '') !== 'pbkdf2-sha256') throw new \RuntimeException('Unsupported encryption.'); $iter = (int)($a['iterations'] ?? 0); if ($iter < 100000 || $iter > 2000000) throw new \RuntimeException('Unsupported encryption settings.'); $key = hash_pbkdf2('sha256', $password, (string)base64_decode((string)$a['salt'], true), $iter, 32, true); $json = openssl_decrypt((string)base64_decode((string)$a['data'], true), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, (string)base64_decode((string)$a['iv'], true), (string)base64_decode((string)$a['tag'], true)); if ($json === false) throw new \RuntimeException('The password is wrong, or the file was changed.'); $a = json_decode($json, true); if (!is_array($a) || ($a['format'] ?? '') !== EVENTPACK_FORMAT) throw new \RuntimeException('The file could not be read.'); } if (empty($a['event']['title']) || empty($a['event']['starts_at']) || !is_array($a['categories'] ?? null)) throw new \RuntimeException('The file is missing the event details.'); if (count($a['categories']) > 200 || count($a['orders'] ?? []) > 20000 || count($a['images'] ?? []) > 80) throw new \RuntimeException('The file holds more than this importer accepts.'); return $a; } /** What the file holds, for the screen before and after import. */ function eventpack_summary(array $pkg): array { $tiers = 0; foreach ($pkg['categories'] as $c) $tiers += count($c['tiers'] ?? []); return ['title' => (string)$pkg['event']['title'], 'categories' => count($pkg['categories']), 'tiers' => $tiers, 'questions' => count($pkg['questions'] ?? []), 'discounts' => count($pkg['discounts'] ?? []), 'announcements' => count($pkg['announcements'] ?? []), 'sponsors' => count($pkg['sponsors'] ?? []), 'images' => count($pkg['images'] ?? []), 'orders' => count($pkg['orders'] ?? []), 'tickets' => array_sum(array_map(fn($o) => count($o['tickets'] ?? []), $pkg['orders'] ?? [])), 'source' => ($pkg['source']['app'] ?? '?') . ' ' . ($pkg['source']['version'] ?? '')]; } function eventpack_save_image(array $pkg, ?string $key): ?string { if (!$key || !isset($pkg['images'][$key]['data'])) return null; $bytes = base64_decode((string)$pkg['images'][$key]['data'], true); if ($bytes === false || strlen($bytes) > 8 * 1024 * 1024) return null; $tmp = tempnam(get_temp_dir(), 'gcimg'); if ($tmp === false) return null; try { if (!gatecraft_fs()->put_contents($tmp, $bytes, gatecraft_file_mode())) return null; $info = wp_getimagesize($tmp); if (!$info || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) return null; return save_image_path($tmp, 2400); } catch (Throwable $e) { return null; } finally { wp_delete_file($tmp); } } function eventpack_unique_slug(string $slug): string { $slug = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($slug)) ?? '', '-') ?: 'event'; $slug = substr($slug, 0, 100); $try = $slug; $n = 1; while (qv('SELECT 1 FROM {events} WHERE slug = ?', [$try])) $try = $slug . '-' . (++$n); return $try; } /** * Create the event from a package, as a draft. Nothing is changed on existing events. * @return array{event_id:int, notes:string[], counts:array} */ function eventpack_import(array $pkg, bool $withBookings, ?int $userId = null): array { $notes = []; $counts = ['orders' => 0, 'tickets' => 0, 'skipped_orders' => 0]; $eventId = 0; tx(function () use ($pkg, $withBookings, $userId, &$notes, &$counts, &$eventId) { $venueId = null; $sectionIds = []; if (!empty($pkg['venue']['name'])) { // Rows from a file are never trusted for ids or links to other rows: those are stripped here and set below. $v = eventpack_row($pkg['venue']); $secs = (array)($pkg['venue']['sections'] ?? []); unset($v['sections']); $venueId = qv('SELECT id FROM {venues} WHERE name = ? AND COALESCE(city, "") = ? LIMIT 1', [$v['name'], (string)($v['city'] ?? '')]); $newVenue = !$venueId; if ($venueId) { $notes[] = 'Used the existing venue "' . $v['name'] . '".'; } else { $v['created_at'] = $v['updated_at'] = now(); [$venueId] = eventpack_insert('venues', $v); $notes[] = 'Created the venue "' . $v['name'] . '".'; } $venueId = (int)$venueId; $missing = 0; foreach ($secs as $s) { if (!is_array($s) || !is_scalar($s['name'] ?? null) || (string)$s['name'] === '') continue; $sid = qv('SELECT id FROM {venue_sections} WHERE venue_id = ? AND name = ?', [$venueId, (string)$s['name']]); // An existing venue is never changed by an import: sections it lacks are left out. if (!$sid && $newVenue) { $s = eventpack_row($s); $s['venue_id'] = $venueId; [$sid] = eventpack_insert('venue_sections', $s); } if (!$sid) { $missing++; continue; } $sectionIds[(string)$s['name']] = (int)$sid; } if ($missing) $notes[] = $missing . ' section(s) in the file are not part of the existing venue here, so tickets for them have no section.'; } $e = eventpack_row($pkg['event']); if (isset($e['description'])) $e['description'] = rich_clean((string)$e['description']); $e['slug'] = eventpack_unique_slug((string)($e['slug'] ?? $e['title'])); if ($e['slug'] !== ($pkg['event']['slug'] ?? '')) $notes[] = 'The page address was already in use, so this event is at "' . $e['slug'] . '".'; $e['status'] = 'draft'; $e['venue_id'] = $venueId; $e['cover_image'] = eventpack_save_image($pkg, $e['cover_image'] ?? null); $e['created_by'] = $userId; $e['created_at'] = $e['updated_at'] = now(); [$eventId, $d] = eventpack_insert('events', $e); if ($d) $notes[] = 'This system has no setting for: ' . implode(', ', $d) . '. Those were left out.'; $catIds = []; $tierIds = []; foreach ($pkg['categories'] as $c) { $tiers = (array)($c['tiers'] ?? []); $ref = (string)($c['ref'] ?? ''); $c = eventpack_row($c); $c['event_id'] = $eventId; $c['venue_section_id'] = $sectionIds[(string)($c['section'] ?? '')] ?? null; unset($c['tiers'], $c['ref'], $c['section']); [$cid, $d] = eventpack_insert('ticket_categories', $c); $catIds[$ref] = $cid; foreach ($tiers as $t) { $tref = (string)($t['ref'] ?? ''); $t = eventpack_row($t); unset($t['ref']); $t['category_id'] = $cid; [$tid] = eventpack_insert('ticket_tiers', $t); $tierIds[$tref] = $tid; } } foreach ((array)($pkg['questions'] ?? []) as $q) { $cat = $q['category'] ?? null; $q = eventpack_row($q); unset($q['category']); $q['event_id'] = $eventId; $q['category_id'] = $cat ? ($catIds[$cat] ?? null) : null; if (eventpack_has_table('event_questions')) eventpack_insert('event_questions', $q); } $discIds = []; foreach ((array)($pkg['discounts'] ?? []) as $i => $dsc) { $cat = $dsc['category'] ?? null; $dsc = eventpack_row($dsc); unset($dsc['category']); $dsc['event_id'] = $eventId; $dsc['category_id'] = $cat ? ($catIds[$cat] ?? null) : null; $dsc['created_at'] = $dsc['created_at'] ?? now(); if (!empty($dsc['code']) && qv('SELECT 1 FROM {discounts} WHERE code = ?', [$dsc['code']])) { $notes[] = 'The discount code "' . $dsc['code'] . '" already exists here. It was imported switched off with no code.'; $dsc['code'] = null; $dsc['is_active'] = 0; } [$discIds[$i]] = eventpack_insert('discounts', $dsc); } if (!empty($pkg['announcements'])) { if (eventpack_has_table('event_announcements')) foreach ($pkg['announcements'] as $a) { $a = eventpack_row($a); if (isset($a['body'])) $a['body'] = rich_clean((string)$a['body']); $a['event_id'] = $eventId; $a['created_by'] = $userId; eventpack_insert('event_announcements', $a); } else $notes[] = 'This system has no announcements, so the ' . count($pkg['announcements']) . ' in the file were left out.'; } if (!empty($pkg['sponsors'])) { if (eventpack_has_table('event_sponsors')) foreach ($pkg['sponsors'] as $s) { $s = eventpack_row($s); $s['event_id'] = $eventId; $s['logo'] = eventpack_save_image($pkg, $s['logo'] ?? null); $s['image'] = eventpack_save_image($pkg, $s['image'] ?? null); eventpack_insert('event_sponsors', $s); } else $notes[] = 'This system has no sponsors block, so the ' . count($pkg['sponsors']) . ' sponsor(s) in the file were left out.'; } if ($withBookings && !empty($pkg['orders'])) { $orderDropped = []; foreach ($pkg['orders'] as $o) { $items = (array)($o['items'] ?? []); $tickets = (array)($o['tickets'] ?? []); $disc = $o['discount'] ?? null; $o = eventpack_row($o, ['stripe_session_id']); unset($o['items'], $o['tickets'], $o['discount']); $clash = qv('SELECT 1 FROM {orders} WHERE reference = ? OR public_token = ?', [$o['reference'] ?? '', $o['public_token'] ?? '']); foreach ($tickets as $t) if (!$clash && qv('SELECT 1 FROM {tickets} WHERE code = ?', [$t['code'] ?? ''])) $clash = true; if ($clash) { $counts['skipped_orders']++; continue; } $o['event_id'] = $eventId; $o['discount_id'] = $disc !== null ? ($discIds[$disc] ?? null) : null; $o['user_id'] = null; $o['stripe_session_id'] = null; [$oid, $od] = eventpack_insert('orders', $o); foreach ($od as $col) $orderDropped[$col] = 1; foreach ($items as $it) eventpack_insert('order_items', ['order_id' => $oid, 'category_id' => $catIds[$it['category']] ?? 0, 'tier_id' => $tierIds[$it['tier']] ?? 0, 'qty' => $it['qty'], 'unit_price' => $it['unit_price']]); foreach ($tickets as $t) { $c = $t['category'] ?? ''; $ti = $t['tier'] ?? ''; unset($t['category'], $t['tier']); eventpack_insert('tickets', ['order_id' => $oid, 'category_id' => $catIds[$c] ?? 0, 'tier_id' => $tierIds[$ti] ?? 0] + eventpack_row($t)); $counts['tickets']++; } $counts['orders']++; } if ($orderDropped) $notes[] = 'This system has no place for these booking details, so they were left out: ' . implode(', ', array_keys($orderDropped)) . '.'; if ($counts['skipped_orders']) $notes[] = $counts['skipped_orders'] . ' bookings were skipped because a booking reference or ticket code already exists here.'; $notes[] = 'Card payments were not carried over: this system cannot confirm or refund them. Unpaid bookings expire as usual.'; } elseif (!empty($pkg['orders'])) { $notes[] = 'The file holds ' . count($pkg['orders']) . ' bookings. They were not imported, as you chose.'; } }); return ['event_id' => $eventId, 'notes' => $notes, 'counts' => $counts]; }