Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile.all-in-one
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ENV PHP_OPCACHE_ENABLE=1
# Switch to root for installing extensions and packages
USER root

RUN install-php-extensions intl gd
RUN install-php-extensions intl gd zip

RUN apk add --no-cache nodejs yarn nginx supervisor dos2unix

Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,14 @@ JWT_ALGO=HS256
GEO_PROVIDER=google
GOOGLE_MAPS_API_KEY=

# Ticket wallet passes. Values ending in _BASE64 contain the base64-encoded file contents.
APPLE_WALLET_PASS_TYPE_IDENTIFIER=
APPLE_WALLET_TEAM_IDENTIFIER=
APPLE_WALLET_CERTIFICATE_BASE64=
APPLE_WALLET_CERTIFICATE_PASSWORD=
APPLE_WALLET_WWDR_CERTIFICATE_BASE64=
GOOGLE_WALLET_ISSUER_ID=
GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64=

# Only required for SAAS mode and if your're charging fees
# OPEN_EXCHANGE_RATES_APP_ID=
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN echo "" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
echo "user = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
echo "group = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf

RUN install-php-extensions intl imagick gd
RUN install-php-extensions intl imagick gd zip

COPY --chown=www-data:www-data . .

Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ COPY --chown=www-data:www-data . /var/www/html

# Switch to root user to install PHP extensions
USER root
RUN install-php-extensions intl imagick gd
RUN install-php-extensions intl imagick gd zip
USER www-data

RUN chmod -R 755 /var/www/html/storage \
Expand Down
24 changes: 24 additions & 0 deletions backend/app/DataTransferObjects/Wallet/TicketWalletPassData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace HiEvents\DataTransferObjects\Wallet;

use HiEvents\DataTransferObjects\BaseDataObject;

class TicketWalletPassData extends BaseDataObject
{
public function __construct(
public readonly int $eventId,
public readonly string $serialNumber,
public readonly string $eventTitle,
public readonly string $organizerName,
public readonly string $ticketTitle,
public readonly string $attendeeName,
public readonly string $barcodeValue,
public readonly ?string $startDate,
public readonly ?string $endDate,
public readonly string $timezone,
public readonly string $ticketUrl,
) {}
}
9 changes: 9 additions & 0 deletions backend/app/Exceptions/WalletPassNotAvailableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace HiEvents\Exceptions;

use Exception;

class WalletPassNotAvailableException extends Exception {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Attendees;

use Dedoc\Scramble\Attributes\Response as ResponseAttribute;
use HiEvents\Exceptions\WalletPassNotAvailableException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\Attendee\GetTicketWalletPassDataHandler;
use HiEvents\Services\Domain\Wallet\AppleWalletPassService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class DownloadAppleWalletPassAction extends BaseAction
{
public function __construct(
private readonly GetTicketWalletPassDataHandler $handler,
private readonly AppleWalletPassService $walletPassService,
) {}

#[ResponseAttribute(status: 200, description: 'Apple Wallet pass', mediaType: 'application/vnd.apple.pkpass', type: 'string', format: 'binary')]
public function __invoke(int $eventId, string $attendeeShortId): Response|JsonResponse
{
try {
$pass = $this->walletPassService->create($this->handler->handle($eventId, $attendeeShortId));
} catch (WalletPassNotAvailableException $exception) {
return $this->errorResponse($exception->getMessage(), 404);
}

return new Response($pass, 200, [
'Content-Type' => 'application/vnd.apple.pkpass',
'Content-Disposition' => 'attachment; filename="ticket.pkpass"',
'Cache-Control' => 'private, no-store',
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Attendees;

use HiEvents\Exceptions\WalletPassNotAvailableException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\Attendee\GetTicketWalletPassDataHandler;
use HiEvents\Services\Domain\Wallet\GoogleWalletPassService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;

class RedirectGoogleWalletPassAction extends BaseAction
{
public function __construct(
private readonly GetTicketWalletPassDataHandler $handler,
private readonly GoogleWalletPassService $walletPassService,
) {}

public function __invoke(int $eventId, string $attendeeShortId): RedirectResponse|JsonResponse
{
try {
$url = $this->walletPassService->createSaveUrl($this->handler->handle($eventId, $attendeeShortId));
} catch (WalletPassNotAvailableException $exception) {
return $this->errorResponse($exception->getMessage(), 404);
}

return redirect()->away($url);
}
}
7 changes: 7 additions & 0 deletions backend/app/Resources/Attendee/AttendeeResourcePublic.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ public function toArray(Request $request): array
),
),
'locale' => $this->getLocale(),
'wallet_passes' => [
'apple' => collect(config('services.wallet_passes.apple'))
->except('certificate_password')
->every(fn ($value) => filled($value)),
'google' => collect(config('services.wallet_passes.google'))
->every(fn ($value) => filled($value)),
],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Application\Handlers\Attendee;

use Carbon\CarbonImmutable;
use HiEvents\DataTransferObjects\Wallet\TicketWalletPassData;
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Exceptions\WalletPassNotAvailableException;
use HiEvents\Helper\Url;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;

readonly class GetTicketWalletPassDataHandler
{
public function __construct(
private AttendeeRepositoryInterface $attendeeRepository,
private EventRepositoryInterface $eventRepository,
) {}

public function handle(int $eventId, string $attendeeShortId): TicketWalletPassData
{
$attendee = $this->attendeeRepository
->loadRelation(new Relationship(ProductDomainObject::class, name: 'product'))
->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence'))
->findFirstWhere([
AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId,
AttendeeDomainObjectAbstract::EVENT_ID => $eventId,
]);

if (! $attendee || $attendee->getStatus() !== AttendeeStatus::ACTIVE->name) {
throw new WalletPassNotAvailableException(__('This ticket is not available for Wallet.'));
}

$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->findFirstWhere([EventDomainObjectAbstract::ID => $eventId]);

if (! $event) {
throw new WalletPassNotAvailableException(__('This ticket is not available for Wallet.'));
}

$occurrence = $attendee->getEventOccurrence();
$timezone = $event->getTimezone() ?: 'UTC';
$startDate = $occurrence?->getStartDate() ?? $event->getStartDate();
$endDate = $occurrence?->getEndDate() ?? $event->getEndDate();

return new TicketWalletPassData(
eventId: $eventId,
serialNumber: $attendee->getPublicId(),
eventTitle: $event->getTitle(),
organizerName: $event->getOrganizer()?->getName() ?? config('app.name'),
ticketTitle: $attendee->getProduct()?->getTitle() ?? __('Ticket'),
attendeeName: $attendee->getFullName(),
barcodeValue: $attendee->getPublicId(),
startDate: $this->toIso8601($startDate, $timezone),
endDate: $this->toIso8601($endDate, $timezone),
timezone: $timezone,
ticketUrl: sprintf(Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $eventId, $attendee->getShortId()),
);
}

private function toIso8601(?string $date, string $timezone): ?string
{
return $date ? CarbonImmutable::parse($date, $timezone)->toIso8601String() : null;
}
}
93 changes: 93 additions & 0 deletions backend/app/Services/Domain/Wallet/AppleWalletPassService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Domain\Wallet;

use HiEvents\DataTransferObjects\Wallet\TicketWalletPassData;
use HiEvents\Exceptions\WalletPassNotAvailableException;
use PKPass\PKPass;
use Throwable;

class AppleWalletPassService
{
public function create(TicketWalletPassData $pass): string
{
$config = config('services.wallet_passes.apple');
$certificate = base64_decode((string) $config['certificate'], true);
$wwdrCertificate = base64_decode((string) $config['wwdr_certificate'], true);

if (! $certificate || ! $wwdrCertificate || ! $config['pass_type_identifier'] || ! $config['team_identifier']) {
throw new WalletPassNotAvailableException(__('Apple Wallet is not configured.'));
}

$wwdrPath = tempnam(sys_get_temp_dir(), 'hievents-wwdr-');
file_put_contents($wwdrPath, $wwdrCertificate);

try {
$builder = new PKPass;
$builder->setCertificateString($certificate);
$builder->setCertificatePassword((string) $config['certificate_password']);
$builder->setWwdrCertificatePath($wwdrPath);
$builder->setData($this->passDefinition($pass, $config));
$builder->addFile(resource_path('wallet/icon.png'));
$builder->addFile(resource_path('wallet/icon@2x.png'));

return $builder->create();
} catch (Throwable $exception) {
throw new WalletPassNotAvailableException(__('Unable to create the Apple Wallet pass.'), previous: $exception);
} finally {
unlink($wwdrPath);
}
}

private function passDefinition(TicketWalletPassData $pass, array $config): array
{
return [
'formatVersion' => 1,
'passTypeIdentifier' => $config['pass_type_identifier'],
'serialNumber' => $pass->serialNumber,
'teamIdentifier' => $config['team_identifier'],
'organizationName' => $pass->organizerName,
'description' => $pass->eventTitle,
'logoText' => $pass->eventTitle,
'foregroundColor' => 'rgb(255, 255, 255)',
'backgroundColor' => 'rgb(107, 70, 193)',
'labelColor' => 'rgb(237, 233, 254)',
'barcode' => $this->barcode($pass->barcodeValue),
'barcodes' => [$this->barcode($pass->barcodeValue)],
...($pass->startDate ? ['relevantDate' => $pass->startDate] : []),
'eventTicket' => [
'primaryFields' => [[
'key' => 'event',
'label' => __('Event'),
'value' => $pass->eventTitle,
]],
'secondaryFields' => [[
'key' => 'attendee',
'label' => __('Attendee'),
'value' => $pass->attendeeName,
]],
'auxiliaryFields' => array_values(array_filter([
['key' => 'ticket', 'label' => __('Ticket'), 'value' => $pass->ticketTitle],
$pass->startDate ? ['key' => 'date', 'label' => __('Date'), 'value' => $pass->startDate, 'dateStyle' => 'PKDateStyleMedium', 'timeStyle' => 'PKDateStyleShort'] : null,
])),
'backFields' => [[
'key' => 'ticketLink',
'label' => __('View Ticket'),
'value' => $pass->ticketUrl,
]],
],
];
}

private function barcode(string $value): array
{
return [
'format' => 'PKBarcodeFormatQR',
'message' => $value,
'messageEncoding' => 'iso-8859-1',
'altText' => $value,
];
}
}
Loading
Loading