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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"symfony/console": ">=6.0",
"setasign/fpdf": "^1.8",
"setasign/fpdi": "^2.6.4",
"smalot/pdfparser": "^2.12"
"smalot/pdfparser": "^2.12",
"composer/ca-bundle": "^1.5"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.38",
Expand Down
29 changes: 20 additions & 9 deletions src/CustomSleepMixin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,37 @@

namespace Mindee;

use Mindee\Error\MindeeException;
use Mindee\Http\CancellationToken;

trait CustomSleepMixin
{
/**
* Waits for a custom amount of time from either a float or an integer.
* Purposefully waits for one more millisecond on windows due to flakiness in delays between OS.
* Purposefully waits for one more millisecond on Windows due to flakiness in delays between OS.
* @param float|integer $delay Delay in seconds.
* @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation.
*/
protected static function customSleep(float|int $delay): void
protected static function customSleep(float|int $delay, ?CancellationToken $cancellationToken = null): void
{
if ($delay <= 0) {
return;
}

$seconds = (int) $delay;
$nanoseconds = abs($seconds - (float) $delay);
if (
strtoupper(substr(PHP_OS_FAMILY, 0, 7)) === 'WINDOWS'
) {
$endTime = microtime(true) + $delay;
$pollIntervalMicroseconds = 100_000;
while (microtime(true) < $endTime) {
if ($cancellationToken && $cancellationToken->isCancelled()) {
throw new MindeeException("Polling operation was cancelled.");
}
$remainingSeconds = $endTime - microtime(true);
if ($remainingSeconds <= 0) {
break;
}
$sleepMicroseconds = (int) min($pollIntervalMicroseconds, $remainingSeconds * 1_000_000);
usleep($sleepMicroseconds);
}
if (PHP_OS_FAMILY === 'Windows') {
usleep(1000);
}
time_nanosleep($seconds, (int) ($nanoseconds * 1_000_000_000));
}
}
39 changes: 39 additions & 0 deletions src/Http/CancellationToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Mindee\Http;

/**
* Custom Mindee HTTP cancellation token for polling.
*/
class CancellationToken
{
private bool $isCanceled = false;

/**
* Flags the token as canceled.
*/
public function cancel(): void
{
$this->isCanceled = true;
}

/**
* Checks whether the token is canceled.
* @return boolean whether the token is canceled.
*/
public function isCanceled(): bool
{
return $this->isCanceled;
}

/**
* Checks whether the token is canceled, but in British.
* @return boolean whether the token is canceled.
*/
public function isCancelled(): bool
{
return $this->isCanceled;
}
}
36 changes: 36 additions & 0 deletions src/Http/CurlSslConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Mindee\Http;

use Composer\CaBundle\CaBundle;
use CurlHandle;

/**
* Configures TLS certificate verification for cURL handles.
*/
class CurlSslConfig
{
/**
* Enables peer verification and points cURL at a valid CA bundle.
*
* Uses the CA bundle detected by composer/ca-bundle (system store or the
* bundled Mozilla CA set as a fallback), so verification works reliably
* across OSes and PHP builds that ship without a configured CA file.
*
* @param CurlHandle $ch cURL handle to configure.
*/
public static function apply(CurlHandle $ch): void
{
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

$caPathOrFile = CaBundle::getSystemCaRootBundlePath();
if (is_dir($caPathOrFile) || (is_link($caPathOrFile) && is_dir((string) readlink($caPathOrFile)))) {
curl_setopt($ch, CURLOPT_CAPATH, $caPathOrFile);
} else {
curl_setopt($ch, CURLOPT_CAINFO, $caPathOrFile);
}
}
}
79 changes: 75 additions & 4 deletions src/Input/UrlInputSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,88 @@ class UrlInputSource extends InputSource

/**
* @param string $url Input URL.
* @throws MindeeSourceException Throws if the URL isn't secure.
* @throws MindeeSourceException Throws if the URL isn't valid or safe.
*/
public function __construct(string $url)
{
if ((!str_starts_with($url, 'https://'))) {
$this->validateUrl($url);
$this->url = $url;
}

/**
* Validates that a URL is safe to fetch.
*
* Rejects URLs with:
* - non-HTTPS schemes,
* - embedded user credentials,
* - loopback hostnames (localhost, *.localhost),
* - literal loopback, link-local, or private-network IP addresses.
*
* Note: DNS resolution is not performed — a hostname that resolves to a
* private IP will not be caught here.
*
* @param string $url URL to validate.
* @throws MindeeSourceException Throws if the URL is invalid or unsafe.
*/
private function validateUrl(string $url): void
{
$parsed = parse_url($url);
if ($parsed === false) {
throw new MindeeSourceException('Invalid URL', ErrorCode::USER_INPUT_ERROR);
}

if (!isset($parsed['scheme']) || strtolower($parsed['scheme']) !== 'https') {
throw new MindeeSourceException('URL must be HTTPS', ErrorCode::USER_INPUT_ERROR);
}

if (!empty($parsed['user']) || !empty($parsed['pass'])) {
throw new MindeeSourceException(
'URL must be HTTPS',
'URL must not embed user credentials',
ErrorCode::USER_INPUT_ERROR
);
}

$host = strtolower($parsed['host'] ?? '');
if ($host === '') {
throw new MindeeSourceException('URL is missing a host', ErrorCode::USER_INPUT_ERROR);
}

// Strip IPv6 brackets
$host = preg_replace('/^\[|]$/', '', $host);

if (
$host === 'localhost'
|| str_ends_with((string) $host, '.localhost')
|| $host === 'ip6-localhost'
|| $host === 'ip6-loopback'
) {
throw new MindeeSourceException(
'URL host is a loopback address',
ErrorCode::USER_INPUT_ERROR
);
}

if (
$host === '::1'
|| preg_match('/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', (string) $host)
) {
throw new MindeeSourceException(
'URL host is a loopback or private address',
ErrorCode::USER_INPUT_ERROR
);
}

if (
preg_match('/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^192\.168\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^169\.254\.\d{1,3}\.\d{1,3}$/', (string) $host)
) {
throw new MindeeSourceException(
'URL host is a loopback or private address',
ErrorCode::USER_INPUT_ERROR
);
}
$this->url = $url;
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/V1/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Mindee\Error\ErrorCode;
use Mindee\Error\MindeeApiException;
use Mindee\Error\MindeeException;
use Mindee\Http\CancellationToken;
use Mindee\Input\InputSource;
use Mindee\Input\LocalInputSource;
use Mindee\Input\LocalResponse;
Expand Down Expand Up @@ -349,14 +350,16 @@ public function parse(
* @param PredictMethodOptions|null $options Prediction Options.
* @param PollingOptions|null $asyncOptions Async Options. Manages timers.
* @param PageOptions|null $pageOptions Options to apply to the PDF file.
* @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation.
* @throws MindeeApiException Throws if the document couldn't be retrieved in time.
*/
public function enqueueAndParse(
string $predictionType,
InputSource $inputDoc,
?PredictMethodOptions $options = null,
?PollingOptions $asyncOptions = null,
?PageOptions $pageOptions = null
?PageOptions $pageOptions = null,
?CancellationToken $cancellationToken = null,
): AsyncPredictResponse {
if (null === $options) {
$options = new PredictMethodOptions();
Expand All @@ -377,7 +380,7 @@ public function enqueueAndParse(
);
error_log("Successfully enqueued document with job id: " . $enqueueResponse->job->id);

$this->customSleep($asyncOptions->initialDelaySec);
$this->customSleep($asyncOptions->initialDelaySec, $cancellationToken);
$retryCounter = 1;
$pollResults = $this->parseQueued($predictionType, $enqueueResponse->job->id, $options->endpoint);

Expand Down
5 changes: 3 additions & 2 deletions src/V1/Http/BaseEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Mindee\V1\Http;

use CurlHandle;
use Mindee\Http\CurlSslConfig;

/**
* Abstract class for endpoints.
Expand Down Expand Up @@ -38,7 +39,7 @@ protected function initCurlSessionGet(string $queueId): array
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->settings->requestTimeout);
curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
CurlSslConfig::apply($ch);
curl_setopt($ch, CURLOPT_USERAGENT, getUserAgent());

$resp = [
Expand Down Expand Up @@ -72,7 +73,7 @@ public function setFinalCurlOpts(
if ($postFields !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
CurlSslConfig::apply($ch);
curl_setopt($ch, CURLOPT_USERAGENT, getUserAgent());
$resp = [
'data' => curl_exec($ch),
Expand Down
17 changes: 10 additions & 7 deletions src/V2/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Mindee\ClientOptions\PollingOptions;
use Mindee\CustomSleepMixin;
use Mindee\Error\MindeeException;
use Mindee\Http\CancellationToken;
use Mindee\Input\InputSource;
use Mindee\V2\ClientOptions\BaseParameters;
use Mindee\V2\Http\MindeeApiV2;
Expand Down Expand Up @@ -45,7 +46,7 @@ public function __construct(?string $apiKey = null)
* @category Asynchronous
*/
public function enqueue(
InputSource $inputSource,
InputSource $inputSource,
BaseParameters $params
): JobResponse {
return $this->mindeeApi->reqPostEnqueue($inputSource, $params);
Expand Down Expand Up @@ -103,14 +104,16 @@ public function getJob(string $jobId): JobResponse
* @param InputSource $inputDoc Input document to parse.
* @param BaseParameters $params Parameters relating to prediction options.
* @param PollingOptions|null $pollingOptions Options to apply to the polling.
* @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation.
* @return BaseResponse A response containing parsing results.
* @throws MindeeException Throws if enqueueing fails, job fails, or times out.
*/
public function enqueueAndGetResult(
string $responseClass,
InputSource $inputDoc,
BaseParameters $params,
?PollingOptions $pollingOptions = null
string $responseClass,
InputSource $inputDoc,
BaseParameters $params,
?PollingOptions $pollingOptions = null,
?CancellationToken $cancellationToken = null
): BaseResponse {
if (!$pollingOptions) {
$pollingOptions = new PollingOptions();
Expand All @@ -126,7 +129,7 @@ public function enqueueAndGetResult(
$jobId = $enqueueResponse->job->id;
error_log("Successfully enqueued document with job ID: " . $jobId);

$this->customSleep($pollingOptions->initialDelaySec);
$this->customSleep($pollingOptions->initialDelaySec, $cancellationToken);
$retryCounter = 1;
$pollResults = $this->getJob($jobId);

Expand All @@ -144,7 +147,7 @@ public function enqueueAndGetResult(
. ". Job status: " . $pollResults->job->status
);

$this->customSleep($pollingOptions->delaySec);
$this->customSleep($pollingOptions->delaySec, $cancellationToken);
$pollResults = $this->getJob($jobId);
$retryCounter++;
}
Expand Down
3 changes: 2 additions & 1 deletion src/V2/Http/MindeeApiV2.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Mindee\Error\ErrorCode;
use Mindee\Error\MindeeApiException;
use Mindee\Error\MindeeException;
use Mindee\Http\CurlSslConfig;
use Mindee\Input\InputSource;
use Mindee\Input\LocalInputSource;
use Mindee\Input\UrlInputSource;
Expand Down Expand Up @@ -306,7 +307,7 @@ private function initChannel(): bool|CurlHandle
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->requestTimeout);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
CurlSslConfig::apply($ch);

curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent());
return $ch;
Expand Down
Loading
Loading