134 lines
4.1 KiB
PHP
134 lines
4.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace PaypalV2;
|
|
|
|
/**
|
|
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
|
|
*
|
|
* Sicherheit:
|
|
* - TLS-Verifikation ist ERZWUNGEN (kein CURLOPT_SSL_VERIFYPEER=false wie im Legacy-NVP).
|
|
* - Access-Token wird ausserhalb des Webroots zwischengespeichert (chmod 600).
|
|
*/
|
|
final class PayPalClient
|
|
{
|
|
private string $base;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->base = Config::apiBase();
|
|
}
|
|
|
|
/** OAuth2 client_credentials Access-Token, gecacht bis kurz vor Ablauf. */
|
|
public function accessToken(): string
|
|
{
|
|
$cacheFile = self::tokenCacheFile();
|
|
|
|
if (\is_file($cacheFile)) {
|
|
$c = \json_decode((string) @\file_get_contents($cacheFile), true);
|
|
if (
|
|
\is_array($c)
|
|
&& ($c['mode'] ?? '') === Config::mode()
|
|
&& isset($c['token'], $c['expires'])
|
|
&& (int) $c['expires'] > \time() + 60
|
|
) {
|
|
return (string) $c['token'];
|
|
}
|
|
}
|
|
|
|
$creds = Config::creds();
|
|
[$status, $body] = $this->raw(
|
|
'POST',
|
|
'/v1/oauth2/token',
|
|
'grant_type=client_credentials',
|
|
['Content-Type: application/x-www-form-urlencoded'],
|
|
$creds['client_id'] . ':' . $creds['secret']
|
|
);
|
|
|
|
$data = \json_decode($body, true);
|
|
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
|
|
throw new \RuntimeException('PayPal v2: OAuth2 fehlgeschlagen (HTTP ' . $status . ').');
|
|
}
|
|
|
|
@\file_put_contents($cacheFile, \json_encode([
|
|
'token' => (string) $data['access_token'],
|
|
'expires' => \time() + (int) ($data['expires_in'] ?? 300),
|
|
'mode' => Config::mode(),
|
|
]), LOCK_EX);
|
|
@\chmod($cacheFile, 0600);
|
|
|
|
return (string) $data['access_token'];
|
|
}
|
|
|
|
/**
|
|
* Authentifizierter JSON-Request.
|
|
*
|
|
* @param array<string,mixed>|null $payload
|
|
* @param list<string> $extraHeaders
|
|
* @return array{0:int,1:array<mixed>} [HTTP-Status, dekodierter Body]
|
|
*/
|
|
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
|
|
{
|
|
$headers = \array_merge(
|
|
[
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $this->accessToken(),
|
|
],
|
|
$extraHeaders
|
|
);
|
|
|
|
$bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
|
|
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
|
|
|
|
$decoded = $body === '' ? [] : \json_decode($body, true);
|
|
|
|
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
|
|
}
|
|
|
|
/**
|
|
* Roher cURL-Aufruf.
|
|
*
|
|
* @param list<string> $headers
|
|
* @return array{0:int,1:string}
|
|
*/
|
|
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
|
|
{
|
|
$ch = \curl_init($this->base . $path);
|
|
\curl_setopt_array($ch, [
|
|
CURLOPT_CUSTOMREQUEST => $method,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
]);
|
|
|
|
if ($basicAuth !== null) {
|
|
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
|
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
|
|
}
|
|
if ($body !== null) {
|
|
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
}
|
|
|
|
$resp = \curl_exec($ch);
|
|
if ($resp === false) {
|
|
$err = \curl_error($ch);
|
|
\curl_close($ch);
|
|
throw new \RuntimeException('PayPal v2: HTTP-Transportfehler: ' . $err);
|
|
}
|
|
|
|
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
\curl_close($ch);
|
|
|
|
return [$status, (string) $resp];
|
|
}
|
|
|
|
private static function tokenCacheFile(): string
|
|
{
|
|
return Config::dir() . '/.token_cache.json';
|
|
}
|
|
}
|