Skip to content
Merged
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
7 changes: 7 additions & 0 deletions backend/app/Exceptions/UnsafeWebhookUrlException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace HiEvents\Exceptions;

class UnsafeWebhookUrlException extends BaseException {}
28 changes: 28 additions & 0 deletions backend/app/Exports/ValueBinders/FormulaSafeValueBinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace HiEvents\Exports\ValueBinders;

use HiEvents\Services\Infrastructure\Export\SpreadsheetFormulaEscaper;
use Maatwebsite\Excel\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;

class FormulaSafeValueBinder extends DefaultValueBinder
{
private ?SpreadsheetFormulaEscaper $escaper = null;

public function bindValue(Cell $cell, mixed $value): bool
{
$this->escaper ??= app(SpreadsheetFormulaEscaper::class);

if ($this->escaper->isFormulaTrigger($value)) {
$cell->setValueExplicit($value, DataType::TYPE_STRING);

return true;
}

return parent::bindValue($cell, $value);
}
}
31 changes: 20 additions & 11 deletions backend/app/Http/Actions/Questions/CreateQuestionAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
use HiEvents\Resources\Question\QuestionResource;
use HiEvents\Services\Application\Handlers\Question\CreateQuestionHandler;
use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO;
use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class CreateQuestionAction extends BaseAction
{
Expand All @@ -24,17 +26,24 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId): JsonResp
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

$question = $this->createQuestionHandler->handle(UpsertQuestionDTO::fromArray([
'title' => $request->input('title'),
'type' => $request->input('type'),
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
'product_ids' => $request->input('product_ids'),
'belongs_to' => $request->input('belongs_to'),
'is_hidden' => $request->boolean('is_hidden'),
'description' => $request->input('description'),
]));
try {
$question = $this->createQuestionHandler->handle(UpsertQuestionDTO::fromArray([
'title' => $request->input('title'),
'type' => $request->input('type'),
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
'product_ids' => $request->input('product_ids'),
'belongs_to' => $request->input('belongs_to'),
'is_hidden' => $request->boolean('is_hidden'),
'description' => $request->input('description'),
]));
} catch (UnrecognizedProductIdException $exception) {
return $this->errorResponse(
message: $exception->getMessage(),
statusCode: Response::HTTP_UNPROCESSABLE_ENTITY,
);
}

return $this->resourceResponse(
resource: QuestionResource::class,
Expand Down
35 changes: 22 additions & 13 deletions backend/app/Http/Actions/Questions/EditQuestionAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
use HiEvents\Resources\Question\QuestionResource;
use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO;
use HiEvents\Services\Application\Handlers\Question\EditQuestionHandler;
use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class EditQuestionAction extends BaseAction
Expand All @@ -29,19 +31,26 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId, int $ques
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

$question = $this->editQuestionHandler->handle(
questionId: $questionId,
createQuestionDTO: UpsertQuestionDTO::fromArray([
'title' => $request->input('title'),
'type' => QuestionTypeEnum::fromName($request->input('type')),
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
'product_ids' => $request->input('product_ids'),
'is_hidden' => $request->boolean('is_hidden'),
'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')),
'description' => $request->input('description'),
]));
try {
$question = $this->editQuestionHandler->handle(
questionId: $questionId,
createQuestionDTO: UpsertQuestionDTO::fromArray([
'title' => $request->input('title'),
'type' => QuestionTypeEnum::fromName($request->input('type')),
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
'product_ids' => $request->input('product_ids'),
'is_hidden' => $request->boolean('is_hidden'),
'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')),
'description' => $request->input('description'),
]));
} catch (UnrecognizedProductIdException $exception) {
return $this->errorResponse(
message: $exception->getMessage(),
statusCode: Response::HTTP_UNPROCESSABLE_ENTITY,
);
}

return $this->resourceResponse(QuestionResource::class, $question);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO;
use HiEvents\Services\Application\Handlers\Reports\GetOrganizerReportHandler;
use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO;
use HiEvents\Services\Infrastructure\Export\SpreadsheetFormulaEscaper;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
Expand All @@ -18,7 +19,10 @@ class ExportOrganizerReportAction extends BaseAction
{
private const MAX_EXPORT_ROWS = 15000;

public function __construct(private readonly GetOrganizerReportHandler $reportHandler) {}
public function __construct(
private readonly GetOrganizerReportHandler $reportHandler,
private readonly SpreadsheetFormulaEscaper $formulaEscaper,
) {}

/**
* @throws ValidationException
Expand Down Expand Up @@ -60,7 +64,7 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s

foreach ($data as $row) {
$csvRow = $this->formatRowForReportType($row, $reportType);
fputcsv($handle, $csvRow);
fputcsv($handle, $this->formulaEscaper->escapeRow($csvRow));
}

fclose($handle);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class DispatchAttendeeWebhookJob
class DispatchAttendeeWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

Expand Down
3 changes: 2 additions & 1 deletion backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class DispatchCheckInWebhookJob
class DispatchCheckInWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

Expand Down
3 changes: 2 additions & 1 deletion backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class DispatchOrderWebhookJob
class DispatchOrderWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

Expand Down
3 changes: 2 additions & 1 deletion backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class DispatchProductWebhookJob
class DispatchProductWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

Expand Down
85 changes: 85 additions & 0 deletions backend/app/Jobs/Webhook/SecureCallWebhookJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace HiEvents\Jobs\Webhook;

use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\TransferStats;
use HiEvents\Exceptions\UnsafeWebhookUrlException;
use HiEvents\Services\Infrastructure\Webhook\WebhookUrlValidator;
use Spatie\WebhookServer\CallWebhookJob;

class SecureCallWebhookJob extends CallWebhookJob
{
private const MAX_REDIRECTS = 3;

private const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];

/**
* @throws UnsafeWebhookUrlException
*/
protected function createRequest(array $body): Response
{
$client = $this->getClient();
$validator = app(WebhookUrlValidator::class);
$url = $this->webhookUrl;

for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
try {
$target = $validator->validate($url);
} catch (UnsafeWebhookUrlException $exception) {
throw new UnsafeWebhookUrlException($this->describeUrlFailure($exception));
}

$response = $client->request($this->httpVerb, $url, array_merge(
[
'timeout' => $this->requestTimeout,
'verify' => $this->verifySsl,
'headers' => $this->headers,
'allow_redirects' => false,
'curl' => [
CURLOPT_RESOLVE => $target->toCurlResolveEntries(),
],
'on_stats' => function (TransferStats $stats) {
$this->transferStats = $stats;
},
],
$body,
is_null($this->proxy) ? [] : ['proxy' => $this->proxy],
is_null($this->cert) ? [] : ['cert' => [$this->cert, $this->certPassphrase]],
is_null($this->sslKey) ? [] : ['ssl_key' => [$this->sslKey, $this->sslKeyPassphrase]],
));

$location = $this->redirectLocation($response);

if ($location === null) {
return $response;
}

$url = (string) UriResolver::resolve(new Uri($url), new Uri($location));
}

throw new UnsafeWebhookUrlException(
__('The webhook URL exceeded the maximum number of redirects.')
);
}

private function describeUrlFailure(UnsafeWebhookUrlException $exception): string
{
return str_replace(':attribute', __('webhook URL'), $exception->getMessage());
}

private function redirectLocation(Response $response): ?string
{
if (! in_array($response->getStatusCode(), self::REDIRECT_STATUS_CODES, true)) {
return null;
}

$location = trim($response->getHeaderLine('Location'));

return $location === '' ? null : $location;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Models\CapacityAssignment;
use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
Expand All @@ -17,9 +18,19 @@ public function __construct(
private readonly DatabaseManager $databaseManager,
) {}

/**
* @throws ResourceNotFoundException
*/
public function handle(int $id, int $eventId): void
{
$capacityAssignment = $this->capacityAssignmentRepository->findById($id);
$capacityAssignment = $this->capacityAssignmentRepository->findFirstWhere([
'id' => $id,
'event_id' => $eventId,
]);

if ($capacityAssignment === null) {
throw new ResourceNotFoundException(__('Capacity assignment not found'));
}

$productIds = CapacityAssignment::find($id)?->products()->pluck('products.id')->toArray() ?? [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order
$updatedOrder = DB::transaction(function () use ($orderData, $orderShortId, $eventSettings) {
$orderDTO = $orderData->order;

DB::statement('SELECT pg_advisory_xact_lock(hashtext(?))', [$orderShortId]);

$order = $this->getOrder($orderShortId);

$this->occurrenceStatusValidator->assertOrderOccurrencesArePurchasable($order);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace HiEvents\Services\Application\Handlers\Question;

use HiEvents\Exceptions\CannotDeleteEntityException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface;
use HiEvents\Repository\Interfaces\QuestionRepositoryInterface;
use Illuminate\Database\DatabaseManager;
Expand All @@ -18,6 +19,7 @@ public function __construct(

/**
* @throws CannotDeleteEntityException
* @throws ResourceNotFoundException
* @throws Throwable
*/
public function handle(int $questionId, int $eventId): void
Expand All @@ -29,9 +31,19 @@ public function handle(int $questionId, int $eventId): void

/**
* @throws CannotDeleteEntityException
* @throws ResourceNotFoundException
*/
private function deleteQuestion(int $questionId, int $eventId): void
{
$existingQuestion = $this->questionRepository->findFirstWhere([
'id' => $questionId,
'event_id' => $eventId,
]);

if ($existingQuestion === null) {
throw new ResourceNotFoundException(__('Question not found'));
}

$existingAnswers = $this->questionAnswersRepository->findWhere([
'question_id' => $questionId,
]);
Expand Down
Loading
Loading