Carpetas de la galería en PHP: explorar y crear de forma segura

El cuadro de diálogo Insert Gallery dentro de la carpeta blog del ejemplo de PHP, con cuatro imágenes y la ruta /blog

Cuando un sitio tiene unos cientos de imágenes, una sola carpeta plana es difícil de usar. El cuadro de diálogo de la galería puede explorar subcarpetas, volver a la carpeta superior y crear una carpeta nueva con su botón New folder. Esta página muestra la parte de PHP: cómo los nombres de carpeta virtuales del cuadro de diálogo se corresponden con directorios dentro de uploads/, y cómo evitar que un nombre de carpeta recibido en la solicitud llegue a cualquier otro lugar del disco.

Carpetas virtuales, no rutas de disco

El cuadro de diálogo solo envía rutas virtuales: "" para la raíz, blog, products/2026. Siempre usan /, también en Windows, y el servidor nunca las usa directamente como rutas. Cada parte debe ser un nombre de carpeta válido:

PHP · rte/files-lib.php
// Folder name rules: 1-64 chars, letters/digits/space/-/_/., not starting with ".".
// Also refused, because Windows silently rewrites or reserves them:
// a trailing dot or space, and device names like CON or NUL.
function rte_is_valid_name(string $name): bool
{
    return preg_match('/\A[A-Za-z0-9 _\-][A-Za-z0-9 _.\-]{0,63}\z/', $name) === 1
        && preg_match('/[. ]\z/', $name) !== 1
        && preg_match('/\A(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(\..*)?\z/i', $name) !== 1;
}

Las dos últimas reglas existen por Windows. Windows convierte sin avisar abc. en abc y reserva nombres de dispositivo como CON y NUL. Rechazar esos nombres en todos los sistemas hace que un árbol de carpetas se comporte igual después de moverlo de un portátil con Windows a un servidor Linux. Ambos nombres reciben un 400 del ejemplo.

Resolver una carpeta de forma segura

Todas las rutas pasan por los mismos dos pasos. Primero, la ruta virtual se divide por / y cada parte se compara con la regla de nombres. Eso por sí solo rechaza .., /etc, C:\Windows, a\b, las partes vacías y %2e%2e: PHP ha decodificado la cadena de consulta una vez, el % sigue ahí y % no es un carácter permitido. Después, la carpeta se resuelve en el disco y se vuelve a comprobar:

PHP · rte/files-lib.php
// A virtual folder ("", "products", "products/2026") must be a plain list of valid names.
// That alone refuses "..", "/x", "C:\x", "a\b", "a//b", NUL and "%2e%2e" ("%" is not allowed).
function rte_parse_folder(mixed $folder): array
{
    if ($folder === null || $folder === '') {
        return [];
    }
    if (!is_string($folder) || strlen($folder) > 512) {   // ?folder[]=x arrives as an array
        throw new RteHttpError(400, 'Invalid folder.');
    }
    $segments = explode('/', $folder);
    foreach ($segments as $segment) {
        if (!rte_is_valid_name($segment)) {
            throw new RteHttpError(400, 'Invalid folder.');
        }
    }
    return $segments;
}

// Resolves a virtual folder to an existing directory inside the root.
function rte_resolve_folder(mixed $folder): array
{
    $segments = rte_parse_folder($folder);
    if (rte_is_reserved($segments)) {
        throw new RteHttpError(404, 'Folder not found.');
    }
    $root = rte_root();
    $path = $root['dir'];
    foreach ($segments as $segment) {
        $path .= DIRECTORY_SEPARATOR . $segment;
    }
    // realpath() is false for a path that does not exist, and it follows symlinks and
    // Windows junctions, so the check below sees where the folder really is.
    $real = realpath($path);
    if ($real === false || !is_dir($real)) {
        throw new RteHttpError(404, 'Folder not found.');
    }
    if (!rte_is_inside($real, $root['dir'])) {
        throw new RteHttpError(400, 'Invalid folder.');
    }
    return ['virtual' => implode('/', $segments), 'segments' => $segments, 'dir' => $real, 'root' => $root];
}

function rte_is_inside(string $path, string $root): bool
{
    if (DIRECTORY_SEPARATOR === '\\') { // Windows paths are case-insensitive
        $path = strtolower($path);
        $root = strtolower($root);
    }
    return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}

Dos hechos sobre realpath() determinan este código:

  • Devuelve false para una ruta que todavía no existe. Por eso no puedes usarla para comprobar una carpeta que vas a crear. El ejemplo resuelve la carpeta superior existente, la comprueba y luego añade un nombre que ya ha pasado la regla de nombres.
  • Sigue los enlaces simbólicos y las junctions de Windows. Pusimos una junction llamada escape dentro de uploads/ que apuntaba a otra carpeta del proyecto. realpath() devolvió el destino, la comprobación de contención lo rechazó y ?folder=escape recibió un 400. En esa misma máquina, is_link() de PHP 8.2 devolvió false para la junction, así que is_link() por sí solo no la habría detectado.

En Windows, la comprobación de contención compara las rutas sin distinguir mayúsculas y minúsculas, porque allí products y Products son la misma carpeta.

Crear una carpeta

El botón New folder envía action=create-folder, la carpeta superior y el nombre nuevo a la misma URL /rte/gallery. La respuesta es el listado de la carpeta superior, así que la carpeta nueva aparece de inmediato:

PHP · rte/gallery.php
if (($_POST['action'] ?? '') !== 'create-folder') {
    throw new RteHttpError(400, 'Unknown action.');
}
$name = $_POST['name'] ?? '';
if (!is_string($name) || !rte_is_valid_name($name)) {
    throw new RteHttpError(400, 'Folder names use 1-64 letters, digits, spaces, "-", "_" or ".", '
        . 'cannot start with "." or end with "." or a space, and cannot be a device name such as CON or NUL.');
}
$parent = rte_resolve_folder($_POST['folder'] ?? '');
if (rte_is_reserved([...$parent['segments'], $name])) {
    throw new RteHttpError(400, '"users" is reserved for per-user folders.');
}
$path = $parent['dir'] . DIRECTORY_SEPARATOR . $name;
// file_exists() first for a clear 409; mkdir() failing covers two requests racing.
if (file_exists($path) || !@mkdir($path, 0755)) {
    if (file_exists($path)) {
        throw new RteHttpError(409, 'A folder or file with that name already exists.');
    }
    throw new RteHttpError(500, 'The folder could not be created.');
}
return rte_listing($parent); // the parent's listing, so the new folder shows

file_exists() da el 409 claro para un nombre que ya está en uso. Si dos solicitudes compiten, mkdir() falla para la segunda, y también se responde a eso. En Windows, crear Products junto a una carpeta products existente también devolvió 409, porque el sistema de archivos los trata como el mismo nombre.

La comprobación de users corresponde a la opción de carpetas por usuario. En el modo compartido, el ejemplo mantiene oculta uploads/users/ y se niega a crearla, para que las carpetas propias de cada usuario nunca aparezcan en la galería compartida.

La carpeta uploads en el servidor: blog con cuatro imágenes, products, team, users con alice y bob, y cuatro imágenes en la raíz, cada una guardada como nombre-8hex.jpg
Así estaba la carpeta uploads/ del ejemplo después de tomar las imágenes de estas guías.

Cuando falla la creación de una carpeta, el cuadro de diálogo muestra por ahora "The folder could not be created right now." para cualquier error, incluido un 409 por un nombre que ya está en uso. El motivo exacto del servidor está en el cuerpo de la respuesta, que puedes ver con las herramientas de desarrollo del navegador.

Pruébalo

Con el proyecto de ejemplo de esta guía en ejecución en el puerto 5201:

Shell
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5201/rte/gallery
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[{"folder":"blog/2026","name":"2026"}],...}

curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5201/rte/gallery
{"error":"A folder or file with that name already exists."}

curl "http://localhost:5201/rte/gallery?folder=../.."
{"error":"Invalid folder."}

Las respuestas son 200, 409 y 400. Después abre Insert Gallery, haz clic en blog y verás ahí la carpeta nueva 2026. El botón Up te devuelve a la raíz.

La misma guía para otras plataformas

JavaScript · ASP.NET Core · MVC 5 · Web Forms

Para seguir leyendo