# Sprint 1.4 · Storage en Cloudflare R2

> Guía técnica para integrar R2 como storage de imágenes y archivos en Innovium.

## Por qué R2

- **Egress gratis** — crítico porque vamos a servir muchas imágenes
- **API S3-compatible** — usamos `aws-sdk-php`
- **Performance global** — CDN incluido
- **Costo:** 10GB gratis + USD 0.015/GB después

## Setup

### Variables de entorno (`.env`)

```env
R2_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_BUCKET=innovium-storage
R2_ENDPOINT=https://<R2_ACCOUNT_ID>.r2.cloudflarestorage.com
R2_REGION=auto
R2_PUBLIC_URL=  # Opcional, vacío para usar siempre URLs firmadas
R2_SIGNED_URL_TTL=3600  # 1 hora en segundos
```

⚠️ Las credenciales reales **nunca** se commitean. `.env` está en `.gitignore`.

### Composer

`aws/aws-sdk-php` ya está en `composer.json` desde Fase 0. Verificar con:

```bash
composer show aws/aws-sdk-php
```

Si no está, agregar:

```bash
composer require aws/aws-sdk-php
```

---

## Arquitectura del Storage gateway

### `app/Core/Storage.php`

Punto único de entrada para R2. **Ningún controller toca el SDK directamente.**

```php
<?php
declare(strict_types=1);

namespace App\Core;

use Aws\S3\S3Client;
use Aws\S3\Exception\S3Exception;
use RuntimeException;

final class Storage
{
    private static ?S3Client $client = null;

    public static function client(): S3Client
    {
        if (self::$client === null) {
            self::$client = new S3Client([
                'version' => 'latest',
                'region'  => Config::get('R2_REGION', 'auto'),
                'endpoint' => Config::get('R2_ENDPOINT'),
                'credentials' => [
                    'key'    => Config::get('R2_ACCESS_KEY_ID'),
                    'secret' => Config::get('R2_SECRET_ACCESS_KEY'),
                ],
                'use_path_style_endpoint' => true,
            ]);
        }
        return self::$client;
    }

    /**
     * Devuelve el path con prefijo de tenant aplicado.
     * Ej: 'productos/123/foto.jpg' → 'tenants/demo/productos/123/foto.jpg'
     */
    private static function tenantPath(string $path): string
    {
        $tenant = Container::instance()->get('tenant');
        $slug = $tenant->slug;
        $cleanPath = ltrim($path, '/');
        return "tenants/{$slug}/{$cleanPath}";
    }

    public static function put(string $path, string $contents, ?string $contentType = null): string
    {
        $fullPath = self::tenantPath($path);
        try {
            self::client()->putObject([
                'Bucket' => Config::get('R2_BUCKET'),
                'Key'    => $fullPath,
                'Body'   => $contents,
                'ContentType' => $contentType ?? 'application/octet-stream',
            ]);
            return $fullPath;
        } catch (S3Exception $e) {
            throw new RuntimeException("Error subiendo a R2: " . $e->getMessage());
        }
    }

    public static function url(string $path, int $ttl = 3600): string
    {
        $fullPath = self::tenantPath($path);
        $cmd = self::client()->getCommand('GetObject', [
            'Bucket' => Config::get('R2_BUCKET'),
            'Key'    => $fullPath,
        ]);
        $request = self::client()->createPresignedRequest($cmd, "+{$ttl} seconds");
        return (string)$request->getUri();
    }

    public static function delete(string $path): bool
    {
        $fullPath = self::tenantPath($path);
        try {
            self::client()->deleteObject([
                'Bucket' => Config::get('R2_BUCKET'),
                'Key'    => $fullPath,
            ]);
            return true;
        } catch (S3Exception $e) {
            return false;
        }
    }

    public static function exists(string $path): bool
    {
        $fullPath = self::tenantPath($path);
        try {
            self::client()->headObject([
                'Bucket' => Config::get('R2_BUCKET'),
                'Key'    => $fullPath,
            ]);
            return true;
        } catch (S3Exception $e) {
            return false;
        }
    }
}
```

### Uso

```php
// Subir
$r2Path = Storage::put('productos/123/foto.jpg', file_get_contents($localPath), 'image/jpeg');
// $r2Path === 'tenants/demo/productos/123/foto.jpg'

// URL firmada con expiración 1h
$url = Storage::url('productos/123/foto.jpg');

// Eliminar
Storage::delete('productos/123/foto.jpg');
```

**Nunca** pases el path con prefijo `tenants/<slug>/`. **Storage lo hace automáticamente.**

---

## Estructura de paths

```
innovium-storage/
├── tenants/
│   ├── demo/
│   │   ├── productos/
│   │   │   ├── 1/                       ← producto_id
│   │   │   │   ├── 8a3f7b2c-original.jpg
│   │   │   │   └── 8a3f7b2c-thumb.jpg
│   │   │   └── 2/
│   │   │       └── ...
│   │   ├── branding/
│   │   │   ├── logo.png
│   │   │   └── favicon.ico
│   │   ├── firmas/
│   │   │   └── CT-2026-1247-firma.png
│   │   └── contratos/
│   │       └── 2026/05/CT-2026-1247.pdf
│   └── infinia/
│       └── ...
```

**Convenciones:**
- Subdirectorio numérico por entidad (`productos/<id>/`)
- UUID en nombres de archivo (no exponer IDs secuenciales)
- Siempre `<uuid>.<ext>`, nunca `<filename_original>.<ext>`

---

## Upload de imágenes

### `app/Core/Upload.php`

```php
<?php
declare(strict_types=1);

namespace App\Core;

use RuntimeException;

final class UploadedFile
{
    public function __construct(
        public readonly string $tmpPath,
        public readonly string $originalName,
        public readonly int $size,
        public readonly string $mimeType,
        public readonly int $error,
    ) {}

    public function isImage(): bool
    {
        return str_starts_with($this->mimeType, 'image/');
    }

    public function dimensions(): ?array
    {
        if (!$this->isImage()) return null;
        $info = @getimagesize($this->tmpPath);
        return $info ? ['width' => $info[0], 'height' => $info[1]] : null;
    }
}

final class Upload
{
    public static function fromRequest(string $field): ?UploadedFile
    {
        if (!isset($_FILES[$field]) || $_FILES[$field]['error'] === UPLOAD_ERR_NO_FILE) {
            return null;
        }
        return new UploadedFile(
            tmpPath: $_FILES[$field]['tmp_name'],
            originalName: $_FILES[$field]['name'],
            size: $_FILES[$field]['size'],
            mimeType: $_FILES[$field]['type'],
            error: $_FILES[$field]['error'],
        );
    }

    public static function uploadImage(
        UploadedFile $file,
        string $directory,
        int $maxSizeMB = 5,
        array $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'],
        int $minWidth = 400,
        int $minHeight = 400,
    ): string {
        // Validaciones
        if ($file->error !== UPLOAD_ERR_OK) {
            throw new RuntimeException("Error de upload: código {$file->error}");
        }
        if ($file->size > $maxSizeMB * 1024 * 1024) {
            throw new RuntimeException("Archivo excede {$maxSizeMB}MB");
        }
        if (!in_array($file->mimeType, $allowedMimes, true)) {
            throw new RuntimeException("Tipo no permitido: {$file->mimeType}");
        }
        $dims = $file->dimensions();
        if ($dims === null) {
            throw new RuntimeException("No es una imagen válida");
        }
        if ($dims['width'] < $minWidth || $dims['height'] < $minHeight) {
            throw new RuntimeException("Dimensiones mínimas {$minWidth}x{$minHeight}");
        }

        // Generar nombre único
        $ext = match($file->mimeType) {
            'image/jpeg' => 'jpg',
            'image/png'  => 'png',
            'image/webp' => 'webp',
        };
        $uuid = bin2hex(random_bytes(16));
        $path = rtrim($directory, '/') . "/{$uuid}.{$ext}";

        // Subir a R2
        $contents = file_get_contents($file->tmpPath);
        return Storage::put($path, $contents, $file->mimeType);
    }
}
```

### Uso desde un controller

```php
class AdminProductoController
{
    public function uploadImagen(Request $request): Response
    {
        $file = Upload::fromRequest('imagen');
        if ($file === null) {
            return Response::json(['error' => 'Sin archivo'], 400);
        }

        $productoId = (int)$request->input('producto_id');

        try {
            $r2Path = Upload::uploadImage(
                file: $file,
                directory: "productos/{$productoId}",
            );
        } catch (RuntimeException $e) {
            return Response::json(['error' => $e->getMessage()], 422);
        }

        // Crear registro en producto_imagenes
        $imagenId = ProductoImagen::create([
            'producto_id' => $productoId,
            'r2_path' => $r2Path,
            'tipo' => 'galeria',
            'orden' => ProductoImagen::nextOrden($productoId),
        ]);

        // Devolver URL firmada para el preview inmediato
        return Response::json([
            'id' => $imagenId,
            'r2_path' => $r2Path,
            'url' => Storage::url($r2Path),
        ]);
    }
}
```

---

## Servir imágenes en vistas

```php
<?php
// En la vista
foreach ($producto->imagenes() as $imagen) {
    $url = \App\Core\Storage::url($imagen['r2_path']);
    echo "<img src='{$url}' alt='{$imagen['descripcion']}'>";
}
?>
```

**Caching:** las URLs firmadas tienen TTL 1h. Si una página se renderiza y se mantiene abierta más de 1h, las imágenes pueden quedar inválidas. Para AJAX/SPA esto no es problema (se regenera al pedirla). Para vistas server-rendered es razonable.

**Optimización futura (Sprint 2.x):** generar thumbnails automáticos al subir y servirlos. Por ahora servir originales.

---

## Tests con R2 mockeado

```php
// tests/Mock/StorageMock.php
class StorageMock
{
    private static array $files = [];

    public static function put(string $path, string $contents): string
    {
        $fullPath = "tenants/test/{$path}";
        self::$files[$fullPath] = $contents;
        return $fullPath;
    }

    public static function url(string $path): string
    {
        return "https://mock.r2.test/" . $path . "?signed=fake";
    }

    public static function exists(string $path): bool
    {
        return isset(self::$files["tenants/test/{$path}"]);
    }

    public static function reset(): void
    {
        self::$files = [];
    }
}
```

En tests, usar binding del Container para reemplazar `Storage` con `StorageMock`.

---

## Checklist de seguridad

- ✅ Credenciales R2 en `.env`, NO en código
- ✅ `.env` en `.gitignore`
- ✅ URLs firmadas con TTL corto (1h por default)
- ✅ Bucket privado (no acceso público)
- ✅ Validación server-side de tipos y tamaños
- ✅ Nombres de archivos con UUID (no exposición de filenames originales)
- ✅ Prefijo automático por tenant (un tenant nunca puede acceder a archivos de otro)

---

## Trade-offs aceptados

- **No usamos thumbnails** en Sprint 1.4. Las imágenes se sirven en su tamaño original. Sprint 2.x agregamos generación automática.
- **No usamos CDN público.** R2 puede servirse vía Cloudflare CDN gratis, pero requiere bucket público + custom domain. Por ahora usamos URLs firmadas (más seguro).
- **No hay limpieza automática de huérfanos.** Si subiste una imagen y al final no guardaste el producto, queda en R2 sin asociación. Sprint 2.x: cron de limpieza.
