Importa archivos de Backblaze en PHP con la API nativa
La API nativa de Backblaze B2 es una API HTTP, por lo que la extensión cURL de código abierto de PHP basta para importar un objeto sin depender de una biblioteca intermediaria. Este ejemplo obtiene la autorización con la API v4 y descarga un objeto privado a disco mediante streaming. Limita ambas respuestas y solo publica el archivo de destino después de completar la descarga.
Requisitos
- PHP 8.1 o posterior con
ext-curl - Una clave de aplicación de B2 con acceso
readFiles - El ID de la clave de aplicación y la clave de aplicación; son valores diferentes
$ php -r "exit(extension_loaded('curl') ? 0 : 1);"
Usa la API nativa de B2
Guarda este código como import-b2.php. Nunca sigue redirecciones, por lo que las
credenciales no pueden reenviarse al destino de una redirección. Acepta endpoints HTTPS, además de
endpoints HTTP de loopback para pruebas locales.
Importa un objeto
<?php
declare(strict_types=1);
final class CurlTransport
{
private const MAX_AUTH_BYTES = 1_048_576;
/** @return array<string, mixed> */
public function authorize(string $url, string $keyId, string $applicationKey): array
{
$this->assertSafeUrl($url);
$body = '';
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize cURL');
}
try {
if (!curl_setopt_array($curl, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 20,
CURLOPT_USERPWD => $keyId . ':' . $applicationKey,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > self::MAX_AUTH_BYTES) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
])) {
throw new RuntimeException('Could not configure cURL');
}
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($ok !== true || $status !== 200) {
throw new RuntimeException("B2 authorization failed with HTTP {$status}");
}
} finally {
unset($curl);
}
$decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new RuntimeException('B2 returned an invalid authorization response');
}
return $decoded;
}
/** @param resource $output */
public function download(string $url, string $token, $output, int $maxBytes): void
{
$this->assertSafeUrl($url);
$written = 0;
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize cURL');
}
try {
if (!curl_setopt_array($curl, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => ['Authorization: ' . $token],
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 300,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (
$output,
$maxBytes,
&$written,
): int {
$length = strlen($chunk);
if ($written + $length > $maxBytes) {
return 0;
}
$result = fwrite($output, $chunk);
if ($result !== $length) {
return 0;
}
$written += $result;
return $result;
},
])) {
throw new RuntimeException('Could not configure cURL');
}
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($ok !== true || $status !== 200) {
throw new RuntimeException("B2 download failed with HTTP {$status}");
}
} finally {
unset($curl);
}
}
private function assertSafeUrl(string $url): void
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$loopback = $scheme === 'http' && in_array($host, ['127.0.0.1', '::1'], true);
if ($scheme !== 'https' && !$loopback) {
throw new RuntimeException('B2 endpoints must use HTTPS');
}
}
}
/** @param array<string, mixed> $authorization */
function requiredString(array $authorization, string $key): string
{
$value = $authorization[$key] ?? null;
if (!is_string($value) || $value === '') {
throw new RuntimeException("B2 authorization response is missing {$key}");
}
return $value;
}
function encodeObjectName(string $objectName): string
{
$segments = explode('/', $objectName);
if (in_array('.', $segments, true) || in_array('..', $segments, true)) {
throw new InvalidArgumentException('Object names cannot contain dot path segments');
}
return implode('/', array_map('rawurlencode', $segments));
}
function importB2Object(
CurlTransport $transport,
string $authorizeUrl,
string $keyId,
string $applicationKey,
string $bucket,
string $objectName,
string $destination,
int $maxBytes,
): void {
if ($maxBytes < 1 || $bucket === '' || $objectName === '') {
throw new InvalidArgumentException('Bucket, object name, and a positive limit are required');
}
$authorization = $transport->authorize($authorizeUrl, $keyId, $applicationKey);
$apiInfo = $authorization['apiInfo'] ?? null;
$storageApi = is_array($apiInfo) ? ($apiInfo['storageApi'] ?? null) : null;
if (!is_array($storageApi)) {
throw new RuntimeException('B2 authorization response is missing storageApi');
}
$downloadUrl = requiredString($storageApi, 'downloadUrl');
$token = requiredString($authorization, 'authorizationToken');
$url = rtrim($downloadUrl, '/') . '/file/' . rawurlencode($bucket) . '/'
. encodeObjectName($objectName);
$directory = dirname($destination);
$realDirectory = realpath($directory);
if ($realDirectory === false || !is_dir($realDirectory) || !is_writable($realDirectory)) {
throw new RuntimeException('Destination directory must exist and be writable');
}
$temporary = tempnam($realDirectory, '.b2-');
if ($temporary === false) {
throw new RuntimeException('Could not create a temporary destination');
}
if (dirname($temporary) !== $realDirectory) {
@unlink($temporary);
throw new RuntimeException('Temporary file was not created beside the destination');
}
$output = fopen($temporary, 'wb');
if ($output === false) {
@unlink($temporary);
throw new RuntimeException('Could not open the temporary destination');
}
try {
$transport->download($url, $token, $output, $maxBytes);
$flushed = fflush($output);
$closed = fclose($output);
$output = null;
if (!$flushed || !$closed) {
throw new RuntimeException('Could not finish writing the downloaded object');
}
if (!rename($temporary, $destination)) {
throw new RuntimeException('Could not publish the downloaded object');
}
} finally {
if (is_resource($output)) {
fclose($output);
}
if (file_exists($temporary)) {
unlink($temporary);
}
}
}
function main(array $arguments): void
{
foreach (['B2_KEY_ID', 'B2_APPLICATION_KEY', 'B2_BUCKET', 'B2_OBJECT'] as $name) {
if (getenv($name) === false || getenv($name) === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
}
$configuredMaxBytes = getenv('B2_MAX_BYTES');
$maxBytes = filter_var(
$configuredMaxBytes === false ? 100 * 1024 * 1024 : $configuredMaxBytes,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]],
);
if ($maxBytes === false) {
throw new RuntimeException('B2_MAX_BYTES must be a positive integer');
}
importB2Object(
new CurlTransport(),
getenv('B2_AUTHORIZE_URL') ?: 'https://api.backblazeb2.com/b2api/v4/b2_authorize_account',
(string) getenv('B2_KEY_ID'),
(string) getenv('B2_APPLICATION_KEY'),
(string) getenv('B2_BUCKET'),
(string) getenv('B2_OBJECT'),
$arguments[1] ?? (__DIR__ . '/imported-object'),
$maxBytes,
);
}
try {
main($argv);
} catch (Throwable) {
error_log('B2 import failed');
exit(1);
}
B2_KEY_ID='your-key-id' \
B2_APPLICATION_KEY='your-application-key' \
B2_BUCKET='my-bucket' \
B2_OBJECT='photos/sunset.jpg' \
php import-b2.php ./sunset.jpg
El límite de 100 MiB es una política de la aplicación. El código conserva el archivo de destino existente si falla la autorización, la descarga, la validación del tamaño, la escritura en disco o el cambio de nombre.
Adapta el importador
Este importador de alcance específico no enumera objetos ni comprueba su existencia. B2 ofrece
esas operaciones mediante b2_list_file_names y una solicitud
HEAD, pero un servicio que conoce ambos nombres puede descargar directamente.
Manejo seguro y eficiente de archivos
- Restringe la clave al bucket, al prefijo de nombre de archivo y al permiso
readFilesrequeridos. - El ID de la clave es el nombre de usuario de Basic Auth; la clave de aplicación es su contraseña.
- Añade reintentos limitados con variación aleatoria para las respuestas
429y las respuestas transitorias5xxen un proceso de trabajo. - Mantén el archivo temporal y el de destino en el mismo sistema de archivos para que
rename()publique de forma atómica.
Conclusión
La API nativa mantiene compacto este importador de PHP y utiliza el contrato de autorización v4 actual de Backblaze. Para directorios o pipelines de procesamiento, el Robot de importación de Backblaze de Transloadit puede gestionar el flujo de trabajo.
