Skip to content

Subida segura de imágenes ​

Función reutilizable que recibe un archivo de $_FILES, comprueba que sea realmente una imagen, le pone un nombre aleatorio, la redimensiona (para que no subas fotos de 8 MB del celular) y la guarda como JPG o WebP optimizado.

Qué valida ​

  • Errores de subida de PHP (tamaño excedido, subida parcial...).
  • Tamaño máximo configurable.
  • Tipo real del archivo con finfo (no confía en la extensión ni en lo que diga el navegador).
  • Que GD pueda abrirla (descarta archivos corruptos o disfrazados).
  • Re-codifica la imagen: elimina metadatos EXIF (ubicación GPS) y cualquier código incrustado.

La función ​

php
<?php
/**
 * Sube y redimensiona una imagen.
 *
 * @param array  $archivo  Elemento de $_FILES (p. ej. $_FILES['foto'])
 * @param string $destino  Carpeta donde guardar (debe existir y tener permisos de escritura)
 * @param int    $maxAncho Ancho máximo en píxeles
 * @param int    $maxAlto  Alto máximo en píxeles
 * @param int    $maxMB    Tamaño máximo permitido en MB
 * @return string Nombre del archivo guardado (p. ej. "a3f9c1....webp")
 * @throws RuntimeException con un mensaje apto para mostrar al usuario
 */
function subir_imagen(array $archivo, string $destino, int $maxAncho = 1600, int $maxAlto = 1600, int $maxMB = 5): string
{
    // 1) Errores de PHP
    if (!isset($archivo['error']) || is_array($archivo['error'])) {
        throw new RuntimeException('Petición no válida.');
    }
    switch ($archivo['error']) {
        case UPLOAD_ERR_OK: break;
        case UPLOAD_ERR_NO_FILE: throw new RuntimeException('No seleccionaste ninguna imagen.');
        case UPLOAD_ERR_INI_SIZE:
        case UPLOAD_ERR_FORM_SIZE: throw new RuntimeException("La imagen supera el tamaño permitido.");
        default: throw new RuntimeException('Error al subir el archivo.');
    }

    // 2) Tamaño
    if ($archivo['size'] > $maxMB * 1024 * 1024) {
        throw new RuntimeException("La imagen no debe pesar más de {$maxMB} MB.");
    }

    // 3) Tipo MIME real
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($archivo['tmp_name']);
    $permitidos = [
        'image/jpeg' => 'imagecreatefromjpeg',
        'image/png'  => 'imagecreatefrompng',
        'image/webp' => 'imagecreatefromwebp',
        'image/gif'  => 'imagecreatefromgif',
    ];
    if (!isset($permitidos[$mime])) {
        throw new RuntimeException('Formato no permitido. Usa JPG, PNG, WebP o GIF.');
    }

    // 4) Abrir con GD
    [$ancho, $alto] = getimagesize($archivo['tmp_name']) ?: [0, 0];
    if ($ancho === 0 || $ancho * $alto > 40_000_000) { // evita "bombas" de 40 megapíxeles
        throw new RuntimeException('La imagen no es válida o es demasiado grande.');
    }
    $origen = @$permitidos[$mime]($archivo['tmp_name']);
    if (!$origen) {
        throw new RuntimeException('No se pudo procesar la imagen.');
    }

    // Corrige la orientación de fotos de celular (EXIF)
    if ($mime === 'image/jpeg' && function_exists('exif_read_data')) {
        $exif = @exif_read_data($archivo['tmp_name']);
        $giro = ['3' => 180, '6' => -90, '8' => 90][$exif['Orientation'] ?? ''] ?? 0;
        if ($giro) {
            $origen = imagerotate($origen, $giro, 0);
            [$ancho, $alto] = [imagesx($origen), imagesy($origen)];
        }
    }

    // 5) Calcular nuevo tamaño manteniendo proporción (nunca agranda)
    $escala = min($maxAncho / $ancho, $maxAlto / $alto, 1);
    $nuevoAncho = (int) round($ancho * $escala);
    $nuevoAlto  = (int) round($alto * $escala);

    $lienzo = imagecreatetruecolor($nuevoAncho, $nuevoAlto);
    // Conserva transparencia (PNG/WebP/GIF)
    imagealphablending($lienzo, false);
    imagesavealpha($lienzo, true);
    imagecopyresampled($lienzo, $origen, 0, 0, 0, 0, $nuevoAncho, $nuevoAlto, $ancho, $alto);

    // 6) Guardar con nombre aleatorio
    if (!is_dir($destino) && !mkdir($destino, 0755, true)) {
        throw new RuntimeException('No se pudo crear la carpeta de destino.');
    }
    $usarWebp = function_exists('imagewebp');
    $nombre = bin2hex(random_bytes(16)) . ($usarWebp ? '.webp' : '.jpg');
    $ruta = rtrim($destino, '/') . '/' . $nombre;

    $ok = $usarWebp ? imagewebp($lienzo, $ruta, 82) : imagejpeg($lienzo, $ruta, 85);

    imagedestroy($origen);
    imagedestroy($lienzo);

    if (!$ok) {
        throw new RuntimeException('No se pudo guardar la imagen.');
    }
    return $nombre;
}

/** Genera una miniatura cuadrada recortada al centro (para tarjetas o avatares) */
function crear_miniatura(string $rutaOrigen, string $rutaDestino, int $lado = 400): void
{
    $info = getimagesize($rutaOrigen);
    $img = match ($info['mime']) {
        'image/webp' => imagecreatefromwebp($rutaOrigen),
        'image/png'  => imagecreatefrompng($rutaOrigen),
        default      => imagecreatefromjpeg($rutaOrigen),
    };
    $w = imagesx($img); $h = imagesy($img);
    $corte = min($w, $h);
    $thumb = imagecreatetruecolor($lado, $lado);
    imagecopyresampled($thumb, $img, 0, 0, (int)(($w - $corte) / 2), (int)(($h - $corte) / 2), $lado, $lado, $corte, $corte);
    imagewebp($thumb, $rutaDestino, 80);
    imagedestroy($img); imagedestroy($thumb);
}

Ejemplo de uso: foto de perfil ​

php
<?php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/subir_imagen.php';
$u = requiere_login();

$mensaje = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    try {
        $nombre = subir_imagen($_FILES['foto'] ?? [], __DIR__ . '/uploads/avatares', 600, 600, 3);
        crear_miniatura(__DIR__ . "/uploads/avatares/$nombre", __DIR__ . "/uploads/avatares/thumb_$nombre", 150);

        db()->prepare('UPDATE usuarios SET avatar = ? WHERE id = ?')->execute([$nombre, $u['id']]);
        $mensaje = '¡Foto actualizada!';
    } catch (RuntimeException $e) {
        $mensaje = $e->getMessage();
    }
}
?>
<form method="post" enctype="multipart/form-data">
  <?= csrf_field() ?>
  <input type="file" name="foto" accept="image/jpeg,image/png,image/webp" required>
  <button>Subir</button>
</form>
<p><?= e($mensaje) ?></p>

Varias imágenes a la vez (galería) ​

php
<?php
// HTML: <input type="file" name="fotos[]" multiple>
// $_FILES['fotos'] llega "al revés"; esta función lo normaliza
function normalizar_archivos(array $files): array
{
    $lista = [];
    foreach ($files['name'] as $i => $nombre) {
        $lista[] = [
            'name'     => $nombre,
            'type'     => $files['type'][$i],
            'tmp_name' => $files['tmp_name'][$i],
            'error'    => $files['error'][$i],
            'size'     => $files['size'][$i],
        ];
    }
    return $lista;
}

$guardadas = [];
foreach (normalizar_archivos($_FILES['fotos']) as $archivo) {
    try {
        $guardadas[] = subir_imagen($archivo, __DIR__ . '/uploads/galeria');
    } catch (RuntimeException $e) {
        $errores[] = $archivo['name'] . ': ' . $e->getMessage();
    }
}

Proteger la carpeta uploads ​

Aunque sólo guardamos imágenes re-codificadas, añade esta capa extra: impide que Apache ejecute PHP dentro de uploads/.

apache
# uploads/.htaccess
php_flag engine off
<FilesMatch "\.(php|phtml|phar|php\d)$">
    Require all denied
</FilesMatch>
Options -Indexes

Configuración de PHP

Si las fotos grandes fallan sin mensaje, revisa upload_max_filesize y post_max_size en php.ini (o en "Select PHP Version → Options" de cPanel). Por ejemplo: upload_max_filesize = 10M y post_max_size = 12M.

Error común

Guardar el archivo con su nombre original ($_FILES['foto']['name']). Puede contener rutas (../../index.php), caracteres raros o sobrescribir otra imagen. Genera siempre un nombre aleatorio y guarda el nombre original en la base de datos si lo necesitas.

Academia Web — aprende, copia y construye.