// */ declare(strict_types=1); /** Percent-encode an object key, keeping the "/" separators. */ function s3_encode_key(string $key): string { return implode('/', array_map('rawurlencode', explode('/', $key))); } function s3_host(): string { return parse_url(config('s3.endpoint'), PHP_URL_HOST); } /** HMAC-SHA256 chain producing the SigV4 signing key. */ function s3_signing_key(string $date): string { $k = hash_hmac('sha256', $date, 'AWS4' . config('s3.secret_key'), true); $k = hash_hmac('sha256', config('s3.region'), $k, true); $k = hash_hmac('sha256', 's3', $k, true); return hash_hmac('sha256', 'aws4_request', $k, true); } /** * SigV4 query-string signing core. Separated from s3_presign() so the * algorithm can be verified against the official AWS example vectors. * Returns the full query string including X-Amz-Signature. */ function s3_presign_query( string $method, string $host, string $canonicalUri, string $accessKey, string $secretKey, string $region, int $ttl, string $amzDate ): string { $date = substr($amzDate, 0, 8); $scope = $date . '/' . $region . '/s3/aws4_request'; $query = [ 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256', 'X-Amz-Credential' => $accessKey . '/' . $scope, 'X-Amz-Date' => $amzDate, 'X-Amz-Expires' => (string)$ttl, 'X-Amz-SignedHeaders' => 'host', ]; ksort($query); $canonicalQuery = implode('&', array_map( fn($k, $v) => rawurlencode($k) . '=' . rawurlencode($v), array_keys($query), $query )); $canonicalRequest = implode("\n", [ strtoupper($method), $canonicalUri, $canonicalQuery, 'host:' . $host, '', 'host', 'UNSIGNED-PAYLOAD', ]); $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $scope, hash('sha256', $canonicalRequest), ]); $k = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true); $k = hash_hmac('sha256', $region, $k, true); $k = hash_hmac('sha256', 's3', $k, true); $k = hash_hmac('sha256', 'aws4_request', $k, true); $signature = hash_hmac('sha256', $stringToSign, $k); return $canonicalQuery . '&X-Amz-Signature=' . $signature; } /** * Build a presigned URL for GET or PUT on an object key. * Only the Host header is signed, so the browser is free to set its own * Content-Type on PUT. */ function s3_presign(string $method, string $key, ?int $ttl = null): string { $ttl ??= (int)config('s3.url_ttl', 3600); $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key); $query = s3_presign_query( $method, s3_host(), $canonicalUri, config('s3.access_key'), config('s3.secret_key'), config('s3.region'), $ttl, gmdate('Ymd\THis\Z') ); return config('s3.endpoint') . $canonicalUri . '?' . $query; } function s3_presign_get(string $key, ?int $ttl = null): string { return s3_presign('GET', $key, $ttl); } function s3_presign_put(string $key, int $ttl = 900): string { return s3_presign('PUT', $key, $ttl); } /** * Server-side signed request (header auth). Used for DELETE. * Returns [httpStatus, responseBody]. */ function s3_request(string $method, string $key): array { $host = s3_host(); $amzDate = gmdate('Ymd\THis\Z'); $date = substr($amzDate, 0, 8); $scope = $date . '/' . config('s3.region') . '/s3/aws4_request'; $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key); $payloadHash = hash('sha256', ''); $canonicalRequest = implode("\n", [ strtoupper($method), $canonicalUri, '', // no query string 'host:' . $host, 'x-amz-content-sha256:' . $payloadHash, 'x-amz-date:' . $amzDate, '', 'host;x-amz-content-sha256;x-amz-date', $payloadHash, ]); $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $scope, hash('sha256', $canonicalRequest), ]); $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date)); $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date' . ', Signature=' . $signature; $ch = curl_init(config('s3.endpoint') . $canonicalUri); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $authorization, 'x-amz-content-sha256: ' . $payloadHash, 'x-amz-date: ' . $amzDate, ], ]); $body = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$status, (string)$body]; } /** Delete one object. S3 returns 204 for success and for already-gone keys. */ function s3_delete(string $key): bool { [$status] = s3_request('DELETE', $key); return $status === 204 || $status === 200 || $status === 404; } /** Delete every S3 object referenced by a gallery (originals + thumbs). */ function s3_delete_gallery_objects(array $gallery): void { foreach ($gallery['images'] ?? [] as $img) { if (!empty($img['key'])) { s3_delete($img['key']); } if (!empty($img['thumb'])) { s3_delete($img['thumb']); } } }