diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml index d0ba89f..214caa1 100644 --- a/.github/workflows/ci-cd.yaml +++ b/.github/workflows/ci-cd.yaml @@ -16,7 +16,7 @@ jobs: "5.6", "7.4", "8.0", - "8.4", + "8.5", ] os: [ubuntu-latest, macOS-latest, windows-latest] steps: diff --git a/CHANGES.md b/CHANGES.md index fec117f..7a0c177 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +## 1.6.4 +* Will use POST instead of GET when retrieving result. +* Remove deprecated `curl_close` for PHP version 8. https://www.php.net/manual/en/function.curl-close.php. +* Support PHP `8.5`. + +## 1.6.3 +* Add minimum TLS 1.2 version to curl options as protocol negotiation on certain openssl/libcurl versions is flaky. + ## 1.6.2 * Remove deprecated curl constant (https://php.watch/versions/8.4/CURLOPT_BINARYTRANSFER-deprecated) diff --git a/lib/Tinify.php b/lib/Tinify.php index e2a970f..e7e7831 100644 --- a/lib/Tinify.php +++ b/lib/Tinify.php @@ -2,14 +2,21 @@ namespace Tinify; -const VERSION = "1.6.2"; +const VERSION = "1.6.4"; class Tinify { + const AUTHENTICATED = true; + const ANONYMOUS = false; + private static $key = NULL; private static $appIdentifier = NULL; private static $proxy = NULL; private static $compressionCount = NULL; + private static $remainingCredits = NULL; + private static $payingState = NULL; + private static $emailAddress = NULL; + private static $client = NULL; public static function setKey($key) { @@ -17,6 +24,16 @@ public static function setKey($key) { self::$client = NULL; } + public static function getKey() { + return self::$key; + } + + public static function createKey($email, $options) { + $body = array_merge(array("email" => $email), $options); + $response = self::getClient(self::ANONYMOUS)->request("post", "/keys", $body); + self::setKey($response->body->key); + } + public static function setAppIdentifier($appIdentifier) { self::$appIdentifier = $appIdentifier; self::$client = NULL; @@ -35,8 +52,32 @@ public static function setCompressionCount($compressionCount) { self::$compressionCount = $compressionCount; } - public static function getClient() { - if (!self::$key) { + public static function getRemainingCredits() { + return self::$remainingCredits; + } + + public static function setRemainingCredits($remainingCredits) { + self::$remainingCredits = $remainingCredits; + } + + public static function getPayingState() { + return self::$payingState; + } + + public static function setPayingState($payingState) { + self::$payingState = $payingState; + } + + public static function getEmailAddress() { + return self::$emailAddress; + } + + public static function setEmailAddress($emailAddress) { + self::$emailAddress = $emailAddress; + } + + public static function getClient($mode = self::AUTHENTICATED) { + if ($mode == self::AUTHENTICATED && !self::$key) { throw new AccountException("Provide an API key with Tinify\setKey(...)"); } @@ -56,6 +97,14 @@ function setKey($key) { return Tinify::setKey($key); } +function getKey() { + return Tinify::getKey(); +} + +function createKey($email, $options) { + return Tinify::createKey($email, $options); +} + function setAppIdentifier($appIdentifier) { return Tinify::setAppIdentifier($appIdentifier); } @@ -72,6 +121,18 @@ function compressionCount() { return Tinify::getCompressionCount(); } +function remainingCredits() { + return Tinify::getRemainingCredits(); +} + +function payingState() { + return Tinify::getPayingState(); +} + +function emailAddress() { + return Tinify::getEmailAddress(); +} + function fromFile($path) { return Source::fromFile($path); } @@ -86,7 +147,8 @@ function fromUrl($string) { function validate() { try { - Tinify::getClient()->request("post", "/shrink"); + Tinify::getClient()->request("get", "/keys/" . Tinify::getKey()); + return true; } catch (AccountException $err) { if ($err->status == 429) return true; throw $err; diff --git a/lib/Tinify/Client.php b/lib/Tinify/Client.php index 4dc820c..2cd87d9 100644 --- a/lib/Tinify/Client.php +++ b/lib/Tinify/Client.php @@ -8,7 +8,7 @@ class Client { const RETRY_COUNT = 1; const RETRY_DELAY = 500; - private $options; + protected $options; public static function userAgent() { $curl = curl_version(); @@ -31,13 +31,19 @@ function __construct($key, $app_identifier = NULL, $proxy = NULL) { throw new ClientException("Your curl version {$version} is outdated; please upgrade to 7.18.1 or higher"); } + # Set minimum TLS version to 1.2, CURL_SSLVERSION_TLSv1_2 is not available in curl < 7.34.0 + # Additionally old PHP versions may not support this constant + $tlsVersion = ($curl["version_number"] < 0x072200) + ? 6 + : (defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6); $this->options = array( CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, - CURLOPT_USERPWD => "api:" . $key, + CURLOPT_USERPWD => $key ? ("api:" . $key) : NULL, CURLOPT_CAINFO => self::caBundle(), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => join(" ", array_filter(array(self::userAgent(), $app_identifier))), + CURLOPT_SSLVERSION => $tlsVersion, ); if ($proxy) { @@ -106,17 +112,25 @@ function request($method, $url, $body = NULL) { if (is_string($response)) { $status = curl_getinfo($request, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($request, CURLINFO_HEADER_SIZE); - curl_close($request); + if (PHP_VERSION_ID < 80000) { + curl_close($request); + } else { + unset($request); + } $headers = self::parseHeaders(substr($response, 0, $headerSize)); $responseBody = substr($response, $headerSize); - if (isset($headers["compression-count"])) { + if ( isset($headers["compression-count"] ) ) { Tinify::setCompressionCount(intval($headers["compression-count"])); } - if ($status >= 200 && $status <= 299) { - return (object) array("body" => $responseBody, "headers" => $headers); + if ( isset( $headers["compression-count-remaining"] ) ) { + Tinify::setRemainingCredits( intval( $headers["compression-count-remaining"] ) ); + } + + if ( isset( $headers["paying-state"] ) ) { + Tinify::setPayingState( $headers["paying-state"] ); } $details = json_decode($responseBody); @@ -130,11 +144,52 @@ function request($method, $url, $body = NULL) { ); } - if ($retries > 0 && $status >= 500) continue; - throw Exception::create($details->message, $details->error, $status); + if ( isset( $headers["email-address"] ) ) { + Tinify::setEmailAddress( $headers["email-address"] ); + } + + $isJson = false; + if (isset($headers["content-type"])) { + /* Parse JSON response bodies. */ + list($contentType) = explode(";", $headers["content-type"], 2); + if (strtolower(trim($contentType)) == "application/json") { + $isJson = true; + } + } + + /* 1xx and 3xx are unexpected and will be treated as error. */ + $isError = $status <= 199 || $status >= 300; + + if ($isJson || $isError) { + /* Parse JSON bodies, always interpret errors as JSON. */ + $responseBody = json_decode($responseBody); + if (!$responseBody) { + $message = sprintf("Error while parsing response: %s (#%d)", + PHP_VERSION_ID >= 50500 ? json_last_error_msg() : "Error", + json_last_error()); + if ($retries > 0 && $status >= 500) continue; + throw Exception::create($message, "ParseError", $status); + } + } + + if ($isError) { + if ($retries > 0 && $status >= 500) continue; + /* When the key doesn't exist a 404 response is given. */ + if ($status == 404) { + throw Exception::create(null, null, $status); + } else { + throw Exception::create($responseBody->message, $responseBody->error, $status); + } + } + + return (object) array("body" => $responseBody, "headers" => $headers); } else { $message = sprintf("%s (#%d)", curl_error($request), curl_errno($request)); - curl_close($request); + if (PHP_VERSION_ID < 80000) { + curl_close($request); + } else { + unset($request); + } if ($retries > 0) continue; throw new ConnectionException("Error while connecting: " . $message); } @@ -146,14 +201,14 @@ protected static function parseHeaders($headers) { $headers = explode("\r\n", $headers); } - $res = array(); + $result = array(); foreach ($headers as $header) { if (empty($header)) continue; $split = explode(":", $header, 2); if (count($split) === 2) { - $res[strtolower($split[0])] = trim($split[1]); + $result[strtolower($split[0])] = trim($split[1]); } } - return $res; + return $result; } } diff --git a/lib/Tinify/Exception.php b/lib/Tinify/Exception.php index d88bdef..2eb2a8c 100644 --- a/lib/Tinify/Exception.php +++ b/lib/Tinify/Exception.php @@ -6,7 +6,7 @@ class Exception extends \Exception { public $status; public static function create($message, $type, $status) { - if ($status == 401 || $status == 429) { + if ($status == 401 || $status == 403 || $status == 404 || $status == 429) { $klass = "Tinify\AccountException"; } else if($status >= 400 && $status <= 499) { $klass = "Tinify\ClientException"; diff --git a/lib/Tinify/Source.php b/lib/Tinify/Source.php index 304040a..ef7c6a1 100644 --- a/lib/Tinify/Source.php +++ b/lib/Tinify/Source.php @@ -53,7 +53,10 @@ public function transform($options) { } public function result() { - $response = Tinify::getClient()->request("get", $this->url, $this->commands); + $has_commands = !empty($this->commands); + $method = $has_commands ? "post" : "get"; + $body = $has_commands ? $this->commands : null; + $response = Tinify::getClient()->request($method, $this->url, $body); return new Result($response->headers, $response->body); } diff --git a/test/TinifyClientTest.php b/test/TinifyClientTest.php index 8cb6761..a09e8b7 100644 --- a/test/TinifyClientTest.php +++ b/test/TinifyClientTest.php @@ -55,6 +55,36 @@ public function testRequestWhenValidShouldUpdateCompressionCount() { $this->assertSame(12, Tinify\getCompressionCount()); } + public function testRequestWhenValidShouldUpdateRemainingCredits() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, "headers" => array("Compression-Count-Remaining" => "488") + )); + $client = new Tinify\Client("key"); + $client->request("get", "/"); + + $this->assertSame(488, Tinify\remainingCredits()); + } + + public function testRequestWhenValidShouldUpdatePayingState() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, "headers" => array("Paying-State" => "free") + )); + $client = new Tinify\Client("key"); + $client->request("get", "/"); + + $this->assertSame("free", Tinify\payingState()); + } + + public function testRequestWhenValidShouldUpdateEmailAddress() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, "headers" => array("Email-Address" => "test@example.com") + )); + $client = new Tinify\Client("key"); + $client->request("get", "/"); + + $this->assertSame("test@example.com", Tinify\emailAddress()); + } + public function testRequestWhenValidWithAppIdShouldIssueRequestWithUserAgent() { CurlMock::register("https://api.tinify.com/", array("status" => 200)); $client = new Tinify\Client("key", "TestApp/0.1"); @@ -63,6 +93,29 @@ public function testRequestWhenValidWithAppIdShouldIssueRequestWithUserAgent() { $this->assertSame(Tinify\Client::userAgent() . " TestApp/0.1", CurlMock::last(CURLOPT_USERAGENT)); } + public function testRequestWhenValidShouldParseJSONBody() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, + "body" => '{"hello":"world"}', + "headers" => array("Content-Type" => "application/JSON; charset=utf-8") + )); + $client = new Tinify\Client("key"); + $response = $client->request("post", "/"); + $this->assertSame("world", $response->body->hello); + } + + public function testRequestWhenValidShouldNotParseBinaryBody() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, + "body" => "binary body", + "headers" => array("Content-Type" => "image/png") + )); + $client = new Tinify\Client("key"); + $response = $client->request("post", "/"); + + $this->assertSame("binary body", $response->body); + } + public function testRequestWhenValidWithProxyShouldIssueRequestWithProxyAuthorization() { CurlMock::register("https://api.tinify.com/", array("status" => 200)); $client = new Tinify\Client("key", NULL, "http://user:pass@localhost:8080"); @@ -73,6 +126,23 @@ public function testRequestWhenValidWithProxyShouldIssueRequestWithProxyAuthoriz $this->assertSame("user:pass", CurlMock::last(CURLOPT_PROXYUSERPWD)); } + public function testRequestWithBadJSONBodyThrowExceptionWithMessage() { + CurlMock::register("https://api.tinify.com/", array( + "status" => 200, + "body" => '', + "headers" => array("Content-Type" => "application/JSON"), + )); + if (PHP_VERSION_ID >= 50500) { + $this->setExpectedExceptionRegExp("Tinify\Exception", + "/Error while parsing response: Syntax error \(#4\) \(HTTP 200\/ParseError\)/"); + } else { + $this->setExpectedExceptionRegExp("Tinify\Exception", + "/Error while parsing response: Error \(#4\) \(HTTP 200\/ParseError\)/"); + } + $client = new Tinify\Client("key"); + $client->request("get", "/"); + } + public function testRequestWithUnexpectedErrorOnceShouldReturnResponse() { CurlMock::register("https://api.tinify.com/", array( "error" => "Failed!", "errno" => 2 diff --git a/test/TinifyExceptionTest.php b/test/TinifyExceptionTest.php new file mode 100644 index 0000000..2329e72 --- /dev/null +++ b/test/TinifyExceptionTest.php @@ -0,0 +1,13 @@ +assertSame(401, $err->status); + } + + public function testStatusShouldReturnNullIfUnset() { + $err = new Tinify\Exception("Message", "Error"); + $this->assertSame(null, $err->status); + } +} diff --git a/test/TinifySourceTest.php b/test/TinifySourceTest.php index 80c5fd8..de4584d 100644 --- a/test/TinifySourceTest.php +++ b/test/TinifySourceTest.php @@ -132,6 +132,31 @@ public function testResultShouldReturnResult() { )); $this->assertInstanceOf("Tinify\Result", Tinify\Source::fromBuffer("png file")->result()); + $this->assertSame("GET", CurlMock::last(CURLOPT_CUSTOMREQUEST)); + } + + /** + * When request does not contain commands, it should use method GET + * when it contains commands, it should have a body and method POST + */ + public function testResultWithCommandsShouldReturnResultUsingPost() { + Tinify\setKey("valid"); + + CurlMock::register("https://api.tinify.com/shrink", array( + "status" => 201, + "headers" => array("Location" => "https://api.tinify.com/some/location"), + )); + + CurlMock::register("https://api.tinify.com/some/location", array( + "status" => 200, "body" => "resized file" + )); + + $source = Tinify\Source::fromBuffer("png file")->resize(array("width" => 400)); + $result = $source->result(); + + $this->assertInstanceOf("Tinify\Result", $result); + $this->assertSame("POST", CurlMock::last(CURLOPT_CUSTOMREQUEST)); + $this->assertSame('{"resize":{"width":400}}', CurlMock::last(CURLOPT_POSTFIELDS)); } public function testPreserveShouldReturnSource() { @@ -162,6 +187,7 @@ public function testPreserveShouldReturnSourceWithData() { $this->assertSame("copyrighted file", Tinify\Source::fromBuffer("png file")->preserve("copyright", "location")->toBuffer()); $this->assertSame("{\"preserve\":[\"copyright\",\"location\"]}", CurlMock::last(CURLOPT_POSTFIELDS)); + $this->assertSame("POST", CurlMock::last(CURLOPT_CUSTOMREQUEST)); } public function testPreserveShouldReturnSourceWithDataForArray() { diff --git a/test/TinifyTest.php b/test/TinifyTest.php index e999668..39348da 100644 --- a/test/TinifyTest.php +++ b/test/TinifyTest.php @@ -3,6 +3,45 @@ use Tinify\CurlMock; class ClientTest extends TestCase { + public function testGetKeyWithoutKeyShouldReturnNull() { + $this->assertSame(NULL, Tinify\getKey()); + } + + public function testGetKeyWithKeyShouldReturnKey() { + Tinify\setKey("abcde"); + $this->assertSame("abcde", Tinify\getKey()); + } + + public function testCreateKeyWithNewEmailShouldSetKey() { + CurlMock::register("https://api.tinify.com/keys", array( + "status" => 202, + "body" => '{"key":"abcdefg123"}', + "headers" => array("Content-Type" => "application/json"), + )); + + Tinify\createKey("user@example.com", array( + "name" => "John", + "identifier" => "My Tinify plugin", + "link" => "https://mywebsite.example.com/admin/settings", + )); + + $this->assertSame("abcdefg123", Tinify\getKey()); + } + + public function testCreateKeyWithDuplicateEmailShouldThrowClientException() { + CurlMock::register("https://api.tinify.com/keys", array( + "status" => 403, + "body" => '{"error":"Duplicate registration","message":"This email address has already been used"}', + )); + + $this->setExpectedException("Tinify\AccountException"); + Tinify\createKey("user@example.com", array( + "name" => "John", + "identifier" => "My Tinify plugin", + "link" => "https://mywebsite.example.com/admin/settings", + )); + } + public function testKeyShouldResetClientWithNewKey() { CurlMock::register("https://api.tinify.com/", array("status" => 200)); Tinify\setKey("abcde"); @@ -63,24 +102,24 @@ public function testSetClientShouldReplaceClient() { public function testValidateWithValidKeyShouldReturnTrue() { Tinify\setKey("valid"); - CurlMock::register("https://api.tinify.com/shrink", array( - "status" => 400, "body" => '{"error":"Input missing","message":"No input"}' + CurlMock::register("https://api.tinify.com/keys/valid", array( + "status" => 200, "body" => '{}' )); $this->assertTrue(Tinify\validate()); } public function testValidateWithLimitedKeyShouldReturnTrue() { - Tinify\setKey("invalid"); - CurlMock::register("https://api.tinify.com/shrink", array( - "status" => 429, "body" => '{"error":"Too many requests","message":"Your monthly limit has been exceeded"}' + Tinify\setKey("limited"); + CurlMock::register("https://api.tinify.com/keys/limited", array( + "status" => 200, "body" => '{}' )); $this->assertTrue(Tinify\validate()); } public function testValidateWithErrorShouldThrowException() { Tinify\setKey("invalid"); - CurlMock::register("https://api.tinify.com/shrink", array( - "status" => 401, "body" => '{"error":"Unauthorized","message":"Credentials are invalid"}' + CurlMock::register("https://api.tinify.com/keys/invalid", array( + "status" => 404, "body" => '{}' )); $this->setExpectedException("Tinify\AccountException"); Tinify\validate(); diff --git a/test/curl_mock.php b/test/curl_mock.php index 9008924..8fe6a00 100644 --- a/test/curl_mock.php +++ b/test/curl_mock.php @@ -17,6 +17,7 @@ class CurlMock { private static $version = array(); public $options = array(); + public $request; public $response; public $closed = false;