Dossiers de galerie avec ASP.NET MVC 5 : parcourir et créer en toute sécurité

La boîte de dialogue Insert Gallery dans le dossier blog de l’exemple MVC 5, qui affiche quatre images avec le chemin /blog

La boîte de dialogue de la galerie ouvre les dossiers avec GET /rte/gallery?folder=blog et les crée avec un POST vers la même URL. Avec ASP.NET MVC 5, cela implique deux actions portant le même nom, et un champ de formulaire dont le nom est aussi une valeur de route réservée de MVC. Cette page montre comment l’exemple met cela en place, et comment un nom de dossier venu du navigateur devient un chemin à l’intérieur de uploads/ sans jamais être considéré d’emblée comme tel.

Une URL, deux actions

La route rte/gallery pointe vers le nom d’action Gallery. La méthode GET est Gallery(string folder), présentée dans le guide sur la galerie. La méthode POST a besoin d’un autre nom C# : [ActionName("Gallery")] lui redonne donc le nom de la route, et [HttpPost] permet à MVC de choisir entre les deux :

C# · Controllers/RteController.cs
[HttpPost]
[ActionName("Gallery")]
public ActionResult GalleryPost(string folder, string name)
{
    try
    {
        // Read "action" from the form directly: as an action parameter it would collide
        // with MVC's own {action} route value.
        if (Request.Unvalidated.Form["action"] != "create-folder")
            throw new RteException(400, "Unknown action.");
        RteFiles.CreateFolder(HttpContext, folder ?? "", name);
        return JsonStatus(200, RteFiles.Listing(HttpContext, folder ?? ""));
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

Le champ nommé « action »

La boîte de dialogue envoie action=create-folder, folder (le parent) et name. Prendre string action en paramètre paraît naturel, mais action est aussi la valeur de route propre à MVC. Nous l’avons testé avec une action jetable [HttpPost] Act(string action). Envoyée sans champ action, elle a reçu action=Act, la valeur de route. Avec le champ, elle a reçu create-folder. La valeur obtenue dépend donc de la route autant que du formulaire. L’exemple lit plutôt Request.Unvalidated.Form["action"], pour que seul le champ de formulaire compte, et un champ absent reçoit la réponse « Unknown action. » (400).

folder et name sont des paramètres ordinaires. Ils sont liés sans validation de la requête parce que le contrôleur porte [ValidateInput(false)]. Sans cet attribut, name=<b>x a provoqué une erreur 500 en HTML due à la validation de la requête. Avec lui, le nom atteint les règles propres à l’exemple et revient sous forme de 400 en JSON.

Créer le dossier

C# · RteFiles.cs
public static void CreateFolder(HttpContextBase context, string parentFolder, string name)
{
    StorageRoot root = GetRoot(context);
    string parentDir = ResolveFolder(root, parentFolder ?? "");
    string problem = NameProblem(name);
    if (problem != null)
        throw new RteException(400, problem);

    string target = Path.Combine(parentDir, name);
    if (Directory.Exists(target) || File.Exists(target))
        throw new RteException(409, "A folder named \"" + name + "\" already exists.");
    Directory.CreateDirectory(target);
}

En cas de succès, l’action renvoie le contenu du dossier parent, si bien que la boîte de dialogue affiche aussitôt le nouveau dossier. Un dossier existant donne un 409, et sur NTFS Blog et blog comptent comme le même nom.

Le dossier ne devient jamais un chemin tel qu’il a été saisi

C# · RteFiles.cs
// Turns a virtual folder ("", "products", "products/2026") into a directory inside the
// root. Every segment must be a valid folder name, which already rules out "..", ".",
// empty segments, "\", ":", drive letters, NUL and leading "/". The resolved path is
// then checked against the root anyway, so a mistake in the rules cannot escape it.
static string ResolveFolder(StorageRoot root, string folder)
{
    if (folder.Length > 0)
    {
        foreach (string segment in folder.Split('/'))
        {
            if (!IsValidName(segment))
                throw new RteException(400, "Invalid folder.");
        }
    }

    string full = Path.GetFullPath(Path.Combine(root.Dir, folder.Replace('/', '\\')));
    if (!(full.TrimEnd('\\') + "\\").StartsWith(root.Dir, StringComparison.OrdinalIgnoreCase))
        throw new RteException(400, "Invalid folder.");
    if (!Directory.Exists(full))
        throw new RteException(404, "Folder not found.");
    return full;
}

Chaque partie du dossier virtuel séparée par / doit être un nom valide, ce qui exclut déjà .., \, : et les parties vides. Le chemin assemblé passe ensuite par Path.GetFullPath et doit toujours commencer par la racine renvoyée par Server.MapPath("~/uploads"). Les règles de nommage sont plus strictes que « lettres, chiffres, espace, -, _, . », pour deux raisons que nous avons rencontrées :

  • Un dossier nommé bin ou App_Data peut être créé, mais le filtrage des requêtes d’IIS traite ces noms comme des segments masqués partout dans une URL. Une image placée dans uploads/bin/ a répondu 404.8. L’exemple refuse ces noms, ainsi que les noms de périphériques Windows comme con et aux.txt.
  • Les noms qui se terminent par . ou par une espace sont refusés, car selon les règles de nommage des fichiers de Microsoft, Windows supprime ces caractères : le dossier sur le disque ne correspondrait donc pas au nom affiché dans la boîte de dialogue.
C# · RteFiles.cs
// 1-64 chars of letters, digits, space, '-', '_', '.'; not starting with '.'.
// Windows extras: no trailing '.' or space (Windows silently drops them, so "a." would
// be the same folder as "a"), and no reserved device or IIS-hidden names.
public static bool IsValidName(string name)
{
    return NameProblem(name) == null;
}

static string NameProblem(string name)
{
    const string rule = "Folder names are 1-64 letters, digits, spaces, '-', '_' or '.', and cannot start with '.'.";
    if (string.IsNullOrEmpty(name) || name.Length > 64 || name[0] == '.')
        return rule;
    foreach (char c in name)
    {
        if (!char.IsLetterOrDigit(c) && c != ' ' && c != '-' && c != '_' && c != '.')
            return rule;
    }
    char last = name[name.Length - 1];
    if (last == '.' || last == ' ')
        return "Folder names cannot end with '.' or a space.";
    string lower = name.ToLowerInvariant();
    string stem = lower.Split('.')[0].Trim();
    if (Array.IndexOf(BlockedNames, lower) >= 0 || Array.IndexOf(BlockedNames, stem) >= 0)
        return "\"" + name + "\" is a reserved name on Windows or IIS.";
    return null;
}

Tester

Avec le projet d’exemple de ce guide en cours d’exécution sur le port 5402, créez un dossier blog dans la boîte de dialogue, puis :

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

curl -F action=create-folder -F folder= -F name=bin http://localhost:5402/rte/gallery
{"error":"\"bin\" is a reserved name on Windows or IIS."}

HTTP 200 avec le nouveau dossier dans la liste, puis HTTP 400. Un PUT vers /rte/gallery reçoit le 404 ordinaire de MVC, car aucune des deux actions n’accepte ce verbe. Dans la boîte de dialogue, New folder demande un nom et affiche immédiatement le dossier.

Le même guide pour d’autres plateformes

JavaScript · PHP · ASP.NET Core · Web Forms

À lire ensuite