get_row($wpdb->prepare("SELECT title,start_datetime,end_datetime FROM %i WHERE id=%d",TICKETDRUM_EVENTS_TABLE,$ticket['event_id']),ARRAY_A); $per_ticket = max(1,(int)$ticket['attendee_count']); for ($q=0;$q<$ticket['quantity'];$q++) for ($a=0;$a<$per_ticket;$a++) { $index = $q*$per_ticket+$a; $units[] = [ 'code' => strtoupper($purchase['salt']).'-'.(int)$ticket['id'].'-'.$index, 'event_title' => ticketdrum_text($event['title']??''), 'event_datetime' => $event?gmdate("D jS M Y, H:i",strtotime($event['start_datetime'])):'', 'ticket_title' => ticketdrum_text($ticket['title']), 'attendee' => '', 'ticket_id' => (int)$ticket['id'], 'index' => $index, ]; } } return $units; } function ticketdrum_generate_tickets_pdf($purchase_id) { global $wpdb; $purchase = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id=%d LIMIT 1",TICKETDRUM_PURCHASES_TABLE,$purchase_id),ARRAY_A); if (!$purchase || $purchase['status']!='paid') return false; $units = ticketdrum_build_ticket_units($purchase); if (count($units)==0) return false; if (!class_exists('TicketDrum_FPDF')) require_once(plugin_dir_path(__FILE__) . '../includes/ticketdrum-fpdf/ticketdrum-fpdf.php'); if (!function_exists('ticketdrum_qr_png')) require_once(plugin_dir_path(__FILE__) . '../includes/ticketdrum-qr.php'); $tmpdir = ticketdrum_tmpdir(); //Random component so the files cannot be guessed during the moment they exist on disk $scratch = wp_generate_password(12,false); if (!class_exists('TicketDrumPDF')) { //FPDF with dashed-line support (for the ticket stub perforation) class TicketDrumPDF extends TicketDrum_FPDF { function SetDash($black=false,$white=false) { if ($black!==false) $s = sprintf('[%.3F %.3F] 0 d',$black*$this->k,$white*$this->k); else $s = '[] 0 d'; $this->_out($s); } } } $pdf = new TicketDrumPDF('P','mm','A4'); $pdf->SetAutoPageBreak(false); /* translators: %s: order number */ $pdf->SetTitle(ticketdrum_pdf_text(sprintf(__('Tickets - Order #%s','ticketdrum'),sprintf('%04d',$purchase_id)))); $per_page = 3; $slot_h = 84; $margin = 12; $qr_files = []; foreach ($units as $i=>$unit) { $slot = $i % $per_page; if ($slot==0) $pdf->AddPage(); $y = 12 + $slot*($slot_h+9); //QR png (temp) $qr_file = $tmpdir.'qr-'.$scratch.'-'.$i.'.png'; ticketdrum_qr_png($unit['code'],$qr_file); $qr_files[] = $qr_file; //Ticket outline + stub divider $pdf->SetDrawColor(22,36,27); $pdf->SetLineWidth(0.6); $pdf->Rect($margin,$y,210-2*$margin,$slot_h); $pdf->SetLineWidth(0.2); $stub_x = 210-$margin-52; $pdf->SetDash(1.5,1.5); $pdf->Line($stub_x,$y,$stub_x,$y+$slot_h); $pdf->SetDash(); //Main area $pdf->SetTextColor(22,36,27); $pdf->SetFont('Helvetica','B',17); $pdf->SetXY($margin+8,$y+11); $pdf->MultiCell($stub_x-$margin-16,8,ticketdrum_pdf_text($unit['event_title'])); $pdf->SetFont('Helvetica','',11); $pdf->SetXY($margin+8,$pdf->GetY()+2); $pdf->Cell(0,6,ticketdrum_pdf_text($unit['event_datetime'])); $pdf->SetFont('Helvetica','B',12); $pdf->SetXY($margin+8,$pdf->GetY()+8); $pdf->Cell(0,6,ticketdrum_pdf_text($unit['ticket_title'])); $pdf->SetFont('Helvetica','',9); $pdf->SetTextColor(90,100,90); $pdf->SetXY($margin+8,$y+$slot_h-14); /* translators: %s: order number */ $pdf->Cell(0,5,ticketdrum_pdf_text(sprintf(__('Order #%s','ticketdrum'),sprintf('%04d',$purchase_id))).' | '.ticketdrum_pdf_text(stripslashes($purchase['name'])).' | '.ticketdrum_pdf_text(get_bloginfo('name')).' | '.ticketdrum_pdf_text(__('ticketing by ticketdrum.com','ticketdrum'))); //Stub: QR + code $pdf->Image($qr_file,$stub_x+6,$y+10,40,40,'PNG'); $pdf->SetFont('Helvetica','B',8); $pdf->SetTextColor(22,36,27); $pdf->SetXY($stub_x+2,$y+54); $pdf->Cell(48,5,$unit['code'],0,0,'C'); $pdf->SetFont('Helvetica','',7); $pdf->SetTextColor(90,100,90); $pdf->SetXY($stub_x+2,$y+60); /* translators: 1: ticket number, 2: number of tickets on the order */ $pdf->Cell(48,4,ticketdrum_pdf_text(sprintf(__('Ticket %1$s of %2$s','ticketdrum'),$i+1,count($units))),0,0,'C'); } $pdf_file = $tmpdir.'tickets-'.$purchase['salt'].'-'.$scratch.'.pdf'; $pdf->Output('F',$pdf_file); foreach ($qr_files as $f) @wp_delete_file($f); return $pdf_file; } //FPDF core fonts are cp1252 only. Convert a character at a time: converting the whole string //fails outright on one unmappable character (an emoji, say), which used to strip every accent. function ticketdrum_pdf_text($text) { $text = html_entity_decode((string)$text,ENT_QUOTES|ENT_HTML5,'UTF-8'); $chars = preg_split('//u',$text,-1,PREG_SPLIT_NO_EMPTY); if ($chars===false) return preg_replace('/[^\x20-\x7E]/','',$text); //Not valid UTF-8 $out = ''; foreach ($chars as $char) { $converted = @iconv('UTF-8','windows-1252//TRANSLIT',$char); $out.= ($converted===false || $converted==='')?'?':$converted; } return $out; } //Rate limit for the public capability-URL endpoints below. // //A nonce cannot protect those endpoints: the buyer arrives from a link in their confirmation //email, days later, with no account and no session on the site, so there is nothing to bind a //nonce to and no user to check a capability against. Authorisation is the token in the URL, the //same pattern WordPress core uses for password reset and post preview links. // //The token itself is the real defence - 32 characters from a 30-character alphabet, about 157 //bits, drawn from wp_rand() - so guessing one is not feasible. This puts a ceiling on the //attempt rate anyway, so the endpoints cannot be hammered by anyone working through candidates. //Only a wrong token counts against the limit; a buyer following their own link never trips it. define('TICKETDRUM_TOKEN_MAX_FAILURES',20); define('TICKETDRUM_TOKEN_FAILURE_WINDOW',15*MINUTE_IN_SECONDS); function ticketdrum_token_failure_key() { $ip = isset($_SERVER['REMOTE_ADDR'])?sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])):''; return 'ticketdrum_tokenfail_'.md5($ip); } //Called before a token is looked up. Ends the request once too many have been got wrong. function ticketdrum_token_throttle() { if ((int)get_transient(ticketdrum_token_failure_key()) >= TICKETDRUM_TOKEN_MAX_FAILURES) wp_die(esc_html__('Too many attempts. Please try again later.','ticketdrum'),esc_html__('Too many requests','ticketdrum'),['response'=>429]); } //Called when a token did not match any order. function ticketdrum_token_failed() { $key = ticketdrum_token_failure_key(); set_transient($key,(int)get_transient($key)+1,TICKETDRUM_TOKEN_FAILURE_WINDOW); } //Public endpoints: ?ticketdrum_tickets=TOKEN (PDF) and ?ticketdrum_invoice=TOKEN (HTML receipt). //Both are read-only - they change no state, so there is nothing here for a forged request to do //that following the link itself would not. See the note above on why the token, not a nonce, is //what authorises them. It is compared in a prepared statement and unlocks exactly one order. function ticketdrum_public_endpoints() { global $wpdb; if (isset($_GET['ticketdrum_tickets'])) { ticketdrum_token_throttle(); $token = sanitize_text_field(wp_unslash($_GET['ticketdrum_tickets'])); $purchase = strlen($token)>=16?$wpdb->get_row($wpdb->prepare("SELECT id FROM %i WHERE access_token=%s AND status='paid' LIMIT 1",TICKETDRUM_PURCHASES_TABLE,$token),ARRAY_A):false; if (!$purchase) { ticketdrum_token_failed(); wp_die(esc_html__('Tickets not found','ticketdrum'),esc_html__('Not found','ticketdrum'),['response'=>404]); } $pdf_file = ticketdrum_generate_tickets_pdf((int)$purchase['id']); if (!$pdf_file) wp_die(esc_html__('No tickets on this order','ticketdrum'),esc_html__('Not found','ticketdrum'),['response'=>404]); header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="tickets-'.sprintf('%04d',$purchase['id']).'.pdf"'); header('Content-Length: '.filesize($pdf_file)); readfile($pdf_file); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming generated PDF to output @wp_delete_file($pdf_file); die(); } if (isset($_GET['ticketdrum_invoice'])) { ticketdrum_token_throttle(); $token = sanitize_text_field(wp_unslash($_GET['ticketdrum_invoice'])); $purchase = strlen($token)>=16?$wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE access_token=%s AND status IN ('paid','refunded') LIMIT 1",TICKETDRUM_PURCHASES_TABLE,$token),ARRAY_A):false; if (!$purchase) { ticketdrum_token_failed(); wp_die(esc_html__('Invoice not found','ticketdrum'),esc_html__('Not found','ticketdrum'),['response'=>404]); } ticketdrum_render_invoice($purchase); die(); } } add_action('template_redirect','ticketdrum_public_endpoints'); function ticketdrum_render_invoice($purchase) { $ticket_data = json_decode($purchase['ticket_data'],true); $business_details = trim(stripslashes(get_option('ticketdrum_invoice_details',''))); $fee = (float)$purchase['fee']; $buyer_paid_fee = ($fee>0 && round($purchase['total'],2)>round($purchase['subtotal']+$purchase['tax'],2)); //Standalone document: the theme's wp_head() never runs here, so the registered stylesheet //is printed on its own rather than left for a hook that will not fire. wp_enqueue_style('ticketdrum-receipt-style'); ?>
# · '.esc_html__('REFUNDED','ticketdrum').'';?>
<>
TicketDrum'));?>
false,'error'=>__('That doesn\'t look like a valid ticket code','ticketdrum')]; $purchase = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE salt=%s AND status='paid' LIMIT 1",TICKETDRUM_PURCHASES_TABLE,strtolower($m[1])),ARRAY_A); if (!$purchase) $purchase = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE salt=%s AND status='paid' LIMIT 1",TICKETDRUM_PURCHASES_TABLE,$m[1]),ARRAY_A); if (!$purchase) return ['result'=>false,'error'=>__('Ticket not found (or order not paid)','ticketdrum')]; $units = ticketdrum_build_ticket_units($purchase); $found = false; foreach ($units as $unit) if ($unit['code']==$code) $found = $unit; if (!$found) return ['result'=>false,'error'=>__('Ticket not found on this order','ticketdrum')]; $checkins = json_decode($purchase['checkins'],true); if (!is_array($checkins)) $checkins = []; $already = isset($checkins[$code]); if (!$already) { $checkins[$code] = ['time'=>gmdate("Y-m-d H:i:s"),'by'=>wp_get_current_user()->user_login]; $wpdb->update(TICKETDRUM_PURCHASES_TABLE,['checkins'=>json_encode($checkins)],['id'=>(int)$purchase['id']]); } return [ 'result' => true, 'already_checked_in' => $already, 'checked_in_at' => wp_date('j M Y, H:i',$already?strtotime($checkins[$code]['time'].' UTC'):time()), //Stored in UTC, shown in site time 'logged_at' => wp_date('H:i:s'), 'event' => $found['event_title'], 'ticket' => $found['ticket_title'], 'name' => stripslashes($purchase['name']), 'order' => sprintf('%04d',$purchase['id']), 'code' => $code, ]; } /*** Calendar ***/ function ticketdrum_generate_ics($purchase_id) { global $wpdb; $purchase = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id=%d LIMIT 1",TICKETDRUM_PURCHASES_TABLE,$purchase_id),ARRAY_A); if (!$purchase) return false; $ticket_data = json_decode($purchase['ticket_data'],true); $event_ids = []; foreach (($ticket_data['tickets']??[]) as $tk) if ($tk['quantity']) $event_ids[] = (int)$tk['event_id']; $event_ids = array_unique($event_ids); if (count($event_ids)==0) return false; $ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TicketDrum//Tickets//EN\r\nMETHOD:PUBLISH\r\n"; foreach ($event_ids as $eid) { $ev = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id=%d",TICKETDRUM_EVENTS_TABLE,$eid),ARRAY_A); if (!$ev) continue; $ics.= "BEGIN:VEVENT\r\n"; $ics.= "UID:ticketdrum-".$eid."-".$purchase['salt']."@".wp_parse_url(home_url(),PHP_URL_HOST)."\r\n"; $ics.= "DTSTAMP:".gmdate('Ymd\THis\Z')."\r\n"; $ics.= "DTSTART:".gmdate('Ymd\THis',strtotime($ev['start_datetime']))."\r\n"; $ics.= "DTEND:".gmdate('Ymd\THis',strtotime($ev['end_datetime'])>0?strtotime($ev['end_datetime']):strtotime($ev['start_datetime'])+7200)."\r\n"; $ics.= "SUMMARY:".ticketdrum_ics_escape(stripslashes($ev['title']))."\r\n"; if (trim($ev['location']??'')!='') $ics.= "LOCATION:".ticketdrum_ics_escape(stripslashes($ev['location']))."\r\n"; $ics.= "DESCRIPTION:".ticketdrum_ics_escape(sprintf( /* translators: 1: order number, 2: tickets page URL */ __('Order #%1$s - tickets: %2$s','ticketdrum'),sprintf('%04d',$purchase_id),home_url('/?ticketdrum_tickets='.ticketdrum_purchase_token($purchase))))."\r\n"; $ics.= "END:VEVENT\r\n"; } $ics.= "END:VCALENDAR\r\n"; $ics_file = ticketdrum_tmpdir().'event-'.$purchase['salt'].'-'.wp_generate_password(12,false).'.ics'; file_put_contents($ics_file,$ics); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- short-lived attachment written to the plugin's own uploads folder return $ics_file; } function ticketdrum_ics_escape($text) { return preg_replace('/([\,;])/','\\\\$1',str_replace(["\r","\n"],['','\\n'],(string)$text)); } function ticketdrum_google_calendar_link($event) { $dates = gmdate('Ymd\THis',strtotime($event['start_datetime'])).'/'.gmdate('Ymd\THis',strtotime($event['end_datetime'])>0?strtotime($event['end_datetime']):strtotime($event['start_datetime'])+7200); return 'https://calendar.google.com/calendar/render?action=TEMPLATE&text='.rawurlencode(stripslashes($event['title'])).'&dates='.$dates.(trim($event['location']??'')!=''?'&location='.rawurlencode(stripslashes($event['location'])):''); } /*** Event structured data: gets events into Google's event rich results ***/ function ticketdrum_event_jsonld($event_id) { global $wpdb; $event = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id=%d AND status='active'",TICKETDRUM_EVENTS_TABLE,$event_id),ARRAY_A); if (!$event || strtotime($event['start_datetime'])<=0) return ''; $tickets = $wpdb->get_results($wpdb->prepare("SELECT price FROM %i WHERE event_id=%d AND active=1",TICKETDRUM_TICKETS_TABLE,$event_id),ARRAY_A); $prices = array_map(function($t){ return (float)$t['price']; },$tickets); $sold_out = $event['max_spaces_available']>0 && ticketdrum_calculate_spaces_taken($event_id)>=$event['max_spaces_available']; $data = [ '@context' => 'https://schema.org', '@type' => 'Event', 'name' => ticketdrum_text($event['title']), 'startDate' => gmdate('c',strtotime($event['start_datetime'])), 'eventStatus' => 'https://schema.org/EventScheduled', 'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode', 'description' => wp_trim_words(wp_strip_all_tags(ticketdrum_text($event['description'])),50), 'organizer' => ['@type'=>'Organization','name'=>get_bloginfo('name'),'url'=>home_url()], ]; if (strtotime($event['end_datetime'])>0) $data['endDate'] = gmdate('c',strtotime($event['end_datetime'])); if (trim($event['location']??'')!='') $data['location'] = ['@type'=>'Place','name'=>ticketdrum_text($event['location']),'address'=>ticketdrum_text($event['location'])]; if (count($prices)>0) $data['offers'] = [ '@type' => 'Offer', 'url' => (is_singular()?get_permalink():home_url(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')))), 'price' => min($prices), 'priceCurrency' => strtoupper(get_option('ticketdrum_currency','usd')), 'availability' => $sold_out?'https://schema.org/SoldOut':'https://schema.org/InStock', 'validFrom' => gmdate('c',strtotime($event['added'])), ]; return ''; } /*** Waitlist: notify everyone waiting when spaces open up on an event ***/ function ticketdrum_waitlist_notify($event_id) { global $wpdb; $event = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id=%d AND status='active'",TICKETDRUM_EVENTS_TABLE,$event_id),ARRAY_A); if (!$event) return; //Only notify if there genuinely is space now if ($event['max_spaces_available']>0 && ticketdrum_calculate_spaces_taken($event_id)>=$event['max_spaces_available']) return; $waiting = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE event_id=%d AND notified=0",TICKETDRUM_WAITLIST_TABLE,$event_id),ARRAY_A); if (count($waiting)==0) return; //Link to the first published page selling this event (falls back to home) $page_id = $wpdb->get_var("SELECT ID FROM {$wpdb->posts} WHERE post_status='publish' AND (post_content LIKE '%wp:ticketdrum/display%' OR post_content LIKE '%[ticketdrum%') ORDER BY ID ASC LIMIT 1"); $link = $page_id?get_permalink($page_id):home_url(); foreach ($waiting as $w) { /* translators: %s: name of the person on the waitlist */ $greeting = $w['name']!=''?sprintf(__('Good news %s!','ticketdrum'),esc_html(stripslashes($w['name']))):esc_html__('Good news!','ticketdrum'); $message = ticketdrum_neaten_email_html(''.$greeting.'
'.sprintf( /* translators: %s: event title */ __('Tickets for %s have become available again.','ticketdrum'),''.esc_html(stripslashes($event['title'])).'').'
'.sprintf( /* translators: %s: link URL */ __('Get your tickets now - they may sell out again quickly.','ticketdrum'),esc_url($link)).'
'.esc_html(get_bloginfo('name')).'
'.sprintf( /* translators: %s: link to the TicketDrum website */ __('Ticket management by %s','ticketdrum'),'TicketDrum').'
'); /* translators: %s: event title */ ticketdrum_mail($w['email'],stripslashes($w['name']),sprintf(__('Tickets available: %s','ticketdrum'),stripslashes($event['title'])),$message); $wpdb->update(TICKETDRUM_WAITLIST_TABLE,['notified'=>1],['id'=>(int)$w['id']]); } }