302 lines
9.5 KiB
PHP
302 lines
9.5 KiB
PHP
<?php
|
|
namespace app\service;
|
|
|
|
use AlibabaCloud\Oss\V2 as Oss;
|
|
use Exception;
|
|
|
|
final class OssService
|
|
{
|
|
private Oss\Client $client;
|
|
private string $bucket;
|
|
private string $region;
|
|
private ?string $endpoint;
|
|
|
|
/**
|
|
* @param array{
|
|
* region: string,
|
|
* bucket: string,
|
|
* endpoint?: string,
|
|
* access_key_id?: string,
|
|
* access_key_secret?: string,
|
|
* session_token?: string
|
|
* } $config
|
|
*/
|
|
public function __construct(array $config)
|
|
{
|
|
$this->region = $config['region'] ?? '';
|
|
$this->bucket = $config['bucket'] ?? '';
|
|
$this->endpoint = $config['endpoint'] ?? null;
|
|
|
|
if ($this->region === '' || $this->bucket === '') {
|
|
throw new Exception('OSS config missing: region/bucket');
|
|
}
|
|
|
|
if (!empty($config['access_key_id']) && !empty($config['access_key_secret'])) {
|
|
$credentialsProvider = new FixedCredentialsProvider(
|
|
(string)$config['access_key_id'],
|
|
(string)$config['access_key_secret'],
|
|
$config['session_token'] ?? null
|
|
);
|
|
} else {
|
|
$credentialsProvider = new Oss\Credentials\EnvironmentVariableCredentialsProvider();
|
|
}
|
|
|
|
$cfg = Oss\Config::loadDefault();
|
|
$cfg->setCredentialsProvider($credentialsProvider);
|
|
$cfg->setRegion($this->region);
|
|
if ($this->endpoint) {
|
|
$cfg->setEndpoint($this->endpoint);
|
|
if ($this->shouldUseCname($this->endpoint)) {
|
|
$cfg->setUseCname(true);
|
|
}
|
|
}
|
|
|
|
$this->client = new Oss\Client($cfg);
|
|
$this->assertCredentialsVisible($config);
|
|
}
|
|
|
|
public function upload(string $source, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
if ($this->isBase64DataUri($source)) {
|
|
return $this->uploadFromBase64($source, $key, $bucket, $options);
|
|
}
|
|
|
|
if ($this->isUrl($source)) {
|
|
return $this->uploadFromUrl($source, $key, $bucket, $options);
|
|
}
|
|
|
|
return $this->uploadFromFile($source, $key, $bucket, $options);
|
|
}
|
|
|
|
public function uploadFromBase64(string $base64, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
$contentType = null;
|
|
$payload = $base64;
|
|
|
|
if (preg_match('/^data:([a-zA-Z0-9.+\\/-]+);base64,(.*)$/s', $base64, $m)) {
|
|
$contentType = strtolower((string)$m[1]);
|
|
$payload = (string)$m[2];
|
|
}
|
|
|
|
$payload = preg_replace('/\\s+/', '', $payload ?? '');
|
|
if ($payload === '') {
|
|
throw new Exception('Base64 payload is empty');
|
|
}
|
|
|
|
$binary = base64_decode($payload, true);
|
|
if ($binary === false) {
|
|
throw new Exception('Invalid base64 payload');
|
|
}
|
|
|
|
if ($contentType && empty($options['contentType'])) {
|
|
$options['contentType'] = $contentType;
|
|
}
|
|
|
|
return $this->uploadFromBinary($binary, $key, $bucket, $options);
|
|
}
|
|
|
|
public function uploadFromBinary(string $binary, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
if ($binary === '') {
|
|
throw new Exception('Binary payload is empty');
|
|
}
|
|
|
|
$stream = fopen('php://temp', 'wb+');
|
|
if ($stream === false) {
|
|
throw new Exception('Unable to create temporary stream for binary upload');
|
|
}
|
|
|
|
fwrite($stream, $binary);
|
|
rewind($stream);
|
|
|
|
return $this->putObjectFromResource($stream, $key, $bucket, $options);
|
|
}
|
|
|
|
public function uploadFromFile(string $filePath, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
if (!is_file($filePath)) {
|
|
throw new Exception("Local file not found: {$filePath}");
|
|
}
|
|
|
|
$fp = fopen($filePath, 'rb');
|
|
if ($fp === false) {
|
|
throw new Exception("Unable to open local file: {$filePath}");
|
|
}
|
|
|
|
return $this->putObjectFromResource($fp, $key, $bucket, $options);
|
|
}
|
|
|
|
public function uploadFromUrl(string $url, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
$fp = @fopen($url, 'rb');
|
|
if ($fp !== false) {
|
|
return $this->putObjectFromResource($fp, $key, $bucket, $options);
|
|
}
|
|
|
|
$tmp = tmpfile();
|
|
if ($tmp === false) {
|
|
throw new Exception('Unable to create tmpfile() for remote download');
|
|
}
|
|
|
|
$this->downloadToStreamByCurl($url, $tmp);
|
|
rewind($tmp);
|
|
|
|
return $this->putObjectFromResource($tmp, $key, $bucket, $options);
|
|
}
|
|
|
|
public function buildObjectUrl(string $key, ?string $bucket = null, bool $useHttps = true): string
|
|
{
|
|
$bucket = $bucket ?: $this->bucket;
|
|
$key = ltrim($key, '/');
|
|
$endpoint = $this->endpoint ?: sprintf('oss-%s.aliyuncs.com', $this->region);
|
|
$host = $this->extractEndpointHost($endpoint);
|
|
$scheme = $useHttps ? 'https' : 'http';
|
|
$encodedKey = implode('/', array_map('rawurlencode', explode('/', $key)));
|
|
|
|
if ($this->shouldUseCname($endpoint)) {
|
|
return sprintf('%s://%s/%s', $scheme, $host, $encodedKey);
|
|
}
|
|
|
|
return sprintf('%s://%s.%s/%s', $scheme, $bucket, $host, $encodedKey);
|
|
}
|
|
|
|
private function putObjectFromResource($resource, string $key, ?string $bucket = null, array $options = []): array
|
|
{
|
|
$bucket = $bucket ?: $this->bucket;
|
|
$key = ltrim($key, '/');
|
|
$metadata = $options['metadata'] ?? [];
|
|
$contentType = $options['contentType'] ?? null;
|
|
$acl = $options['acl'] ?? null;
|
|
|
|
$body = Oss\Utils::streamFor($resource);
|
|
$request = new Oss\Models\PutObjectRequest(
|
|
bucket: $bucket,
|
|
key: $key,
|
|
metadata: $metadata ?: null,
|
|
contentType: $contentType,
|
|
objectAcl: $acl
|
|
);
|
|
$request->body = $body;
|
|
|
|
$result = $this->client->putObject($request);
|
|
|
|
return [
|
|
'statusCode' => $result->statusCode,
|
|
'requestId' => $result->requestId,
|
|
'etag' => $result->etag,
|
|
'bucket' => $bucket,
|
|
'key' => $key,
|
|
];
|
|
}
|
|
|
|
private function downloadToStreamByCurl(string $url, $destStream): void
|
|
{
|
|
if (!function_exists('curl_init')) {
|
|
throw new Exception('cURL extension is required when allow_url_fopen is disabled.');
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_FILE => $destStream,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 5,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_FAILONERROR => true,
|
|
CURLOPT_USERAGENT => 'OssService/1.0',
|
|
]);
|
|
|
|
$ok = curl_exec($ch);
|
|
if ($ok !== true) {
|
|
$err = curl_error($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
curl_close($ch);
|
|
throw new Exception("Failed to download remote file. HTTP={$code}, error={$err}");
|
|
}
|
|
|
|
curl_close($ch);
|
|
}
|
|
|
|
private function isUrl(string $value): bool
|
|
{
|
|
return (bool)filter_var($value, FILTER_VALIDATE_URL);
|
|
}
|
|
|
|
private function isBase64DataUri(string $value): bool
|
|
{
|
|
return (bool)preg_match('/^data:[a-zA-Z0-9.+\\/-]+;base64,/i', $value);
|
|
}
|
|
|
|
private function assertCredentialsVisible(array $config): void
|
|
{
|
|
if (!empty($config['access_key_id']) && !empty($config['access_key_secret'])) {
|
|
return;
|
|
}
|
|
|
|
$ak = getenv('OSS_ACCESS_KEY_ID');
|
|
$sk = getenv('OSS_ACCESS_KEY_SECRET');
|
|
|
|
if (empty($ak) || empty($sk)) {
|
|
throw new Exception(
|
|
"OSS credentials missing in PHP process env. " .
|
|
"getenv('OSS_ACCESS_KEY_ID')/getenv('OSS_ACCESS_KEY_SECRET') is empty. " .
|
|
"Fix: make sure the PHP runtime process can see env vars, or pass access_key_id/access_key_secret into OssService config."
|
|
);
|
|
}
|
|
}
|
|
|
|
private function shouldUseCname(string $endpoint): bool
|
|
{
|
|
$host = strtolower($this->extractEndpointHost($endpoint));
|
|
|
|
if ($host === '' || str_contains($host, 'aliyuncs.com') || str_contains($host, '.oss-')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function extractEndpointHost(string $endpoint): string
|
|
{
|
|
$normalized = preg_match('#^https?://#i', $endpoint) ? $endpoint : 'https://' . ltrim($endpoint, '/');
|
|
return (string)(parse_url($normalized, PHP_URL_HOST) ?: '');
|
|
}
|
|
|
|
public function presignGetObjectUrl(string $key, ?string $bucket = null, int $expireSeconds = 900): array
|
|
{
|
|
$bucket = $bucket ?: $this->bucket;
|
|
$key = ltrim($key, '/');
|
|
|
|
$request = new Oss\Models\GetObjectRequest(bucket: $bucket, key: $key);
|
|
$result = $this->client->presign($request, [
|
|
'expires' => new \DateInterval("PT{$expireSeconds}S"),
|
|
]);
|
|
|
|
return [
|
|
'url' => $result->url,
|
|
'expire' => $expireSeconds,
|
|
'bucket' => $bucket,
|
|
'key' => $key,
|
|
];
|
|
}
|
|
}
|
|
|
|
final class FixedCredentialsProvider implements Oss\Credentials\CredentialsProvider
|
|
{
|
|
private string $ak;
|
|
private string $sk;
|
|
private ?string $token;
|
|
|
|
public function __construct(string $ak, string $sk, ?string $token = null)
|
|
{
|
|
$this->ak = $ak;
|
|
$this->sk = $sk;
|
|
$this->token = $token;
|
|
}
|
|
|
|
public function getCredentials(): Oss\Credentials\Credentials
|
|
{
|
|
return new Oss\Credentials\Credentials($this->ak, $this->sk, $this->token);
|
|
}
|
|
}
|