Pokud ve webové aplikaci řešíme stahování souborů přes HTTP handler, je nutné kromě vlastních dat v responce body v odpovědi správně nastavit HTTP hlavičky Content-Disposition a Content-Type. Tyto hlavičky popisují formát a účel odesílaných dat, aby browser uživateli korektně nabídl soubor ke stažení nebo k otevření. Podíváme se na to jak správně vytvářet obsah těchto hlaviček.
Hlavička Content-Type
Hlavička Content-Type určuje formát souboru, který se vrací v handleru (responce body). Hlavička může vypadat například takto:
Content-Type: text/html;charset=UTF-8
První část uvádí typ média, druhá nepovinná část charset pak určuje jeho kódování. Pro určení typu média se využívá standard MIME (Multipurpose Internet Mail Extensions). Seznam typů najdete například zde, zde, nebo zde.
Hlavička Content-Disposition
Hlavička Content-Disposition určuje, co má klient s obdrženými daty udělat. Vypadá například takto:
Content-Disposition: attachment;filename=Report.pdf
První hodnota je buď attachment nebo inline (inline je výchozí a nemusí se uvést). Attachment podle specifikace určuje, že obsah je oddělený od hlavní zprávy (např. mail message) a jejich zobrazení tedy nemá být automatické, ale vyžaduje akci od uživatele. Při stahování souboru prohlížečem to způsobí, že soubor v něm není rovnou zobrazen, ale je uživateli nabídnuto zda soubor otevřít nebo uložit. Pokud tedy budeme stahovat soubor například typu pdf, při attachment budou nabídnuty možnosti Open / Save / Cancel (viz. obr. z IE), kdyžto při vynechání hodnoty attachment (nebo při hodnotě inline) bude soubor zobrazen v prohlížeči pomoci nainstalovaného prohlížeče PDF souborů (který bude spuštěn přímo uvnitř webového prohlížeče).
Hodnota filename určuje jméno souboru pro download tj. jméno souboru, pod kterým se soubor uloží volbou Save (nebo bude uživateli nabídnuto v dialogu volbou Save As).
Problém se jménem souboru
A právě s určením správného jména souborů v hlavičce Content-Disposition může být problém. Jméno souboru zde totiž musí být správně encodované. Problém uvedu na příkladu, který jsem nedávno řešil. Pokud ve jméně souboru bude znak mezera a jméno by jsme do hlavičky uvedli přímo, tak si s tím některé browsery sice poradí (IE, Chrome), ale například ve FireFoxu se soubor uloží pod jménem pouze do mezery (tím pádem zároveň bez přípony). Jméno souboru by se muselo do hlavičky uvést v uvozovkách.
Protože podobných problému s různými znaky ve jméně souboru je ale více, pojďme najít správné řešení jak jméno do hlavičky vložit tj. podle nějakého standardu.
Vytvoření Hlavičky v .NET
V .NET existuje v namespace System.Net.Mime třída ContentDisposition. Ta jméno souboru podle potřeby encoduje, konkrétně podle standardu RFC 2184 (například jméno s mezerami je vložené do uvozovek) a vytvoří string pro hlavičku Content-Disposition.
var disposition = new System.Net.Mime.ContentDisposition() { FileName = fileDownloadName, Inline = !isAttachment };
Response.AddHeader("Content-Disposition", disposition.ToString());
Problém je ale, že použití této třídy je řešení pouze poloviční, pokud totiž budou v názvu souboru non-ASCII znaky, bude při volání buď vyhozena exception, nebo budou v názvu znaky změněny (například odstranění čárky a háčky).
Pro správné encodování jména souboru s non-ASCII znaky se používá jiný standard - RFC 2231. Ten jméno souboru encoduje pomoci UTF-8 kódování, a zase se jinak chová k mezerám, ty se reprezentují znaky %20, uvozovky se nepoužívají.
Pro stejné jméno souboru tedy může hlavička vypadat například takto (v RFC 2184):
Content-Disposition: attachment;filename="My Report.pdf"
a nebo takto (v RFC 2231):
Content-Disposition: attachment;filename*=UTF-8''My%20Report.pdf
V ASP.NET MVC frameworku je na třídě Controller pomocná metoda File, která při vkládaní obsahu souboru do HttpResponse zařídí i uložení hlavičky Content-Disposition se správně encodovaným názvem souboru, a to právě podle RFC 2231 pokud jméno obsahuje non-ASCII znaky:
foreach (char ch in fileName)
{
if (ch > '\x007f')
{
return CreateRfc2231HeaderValue(fileName);
}
}
//Use System.Net.Mime.ContentDisposition
//...
Třída DownloadHandlerUtil
Protože pro “klasické” ASP.NET v .NETu není žádná metoda, která by do hlavičky jméno souboru encodovala takto správně, použil jsem kód z výše popsané MVC implementace. Kód jsem zobecnil (aby se dalo nastavit attachment i inline) do metody GetContentDispositionHeader. Tu jsem umístil do pomocné třídy DownloadHandlerUtil.
Do této třídy jsem dále naimplementoval obdobu MVC metody File – metodu WriteFileToResponse s parametry response, fileContent, fileDownloadName, isAttachment a contentType. Touto metodou lze do HTTPResponce přímo zapsat obsah souboru včetně nastavení obou hlaviček Content-Disposition a Content-Type.
Třída navíc obsahuje funkcionalitu pro odvození parametrů contentType a isAttachment metody WriteFileToResponse (pokud se neuvedou) podle předaného jména souboru. Kód v handleru používající tuto třídu bude tedy například tento:
string fileName;
byte[] data = GetFile(fileUri, out fileName);
if (data != null)
{
DownloadHandlerUtil.WriteFileToResponse(context.Response, data, fileName);
return;
}
A nyní již implementace třídy DownloadHandlerUtil:
/// <summary>
/// Helper class for writing file content to the HTTP response.
/// </summary>
internal static class DownloadHandlerUtil
{
#region constants
private const string HexDigits = "0123456789ABCDEF";
#endregion
#region action methods
/// <summary>
/// Writes the file content to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileContent">The byte array to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
/// <param name="isAttachment"><c>true</c> if file is attachment; <c>false</c> if file is inline.</param>
/// <param name="contentType">The content type to use for the response.</param>
public static void WriteFileToResponse(HttpResponse response, byte[] fileContent, string fileDownloadName, bool isAttachment, string contentType)
{
WriteFileToResponseInternal(response, fileContent, fileDownloadName, isAttachment, contentType);
}
/// <summary>
/// Writes the file content to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileContent">The byte array to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
/// <param name="isAttachment"><c>true</c> if file is attachment; <c>false</c> if file is inline.</param>
public static void WriteFileToResponse(HttpResponse response, byte[] fileContent, string fileDownloadName, bool isAttachment)
{
WriteFileToResponseInternal(response, fileContent, fileDownloadName, isAttachment, null);
}
/// <summary>
/// Writes the file content to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileContent">The byte array to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
public static void WriteFileToResponse(HttpResponse response, byte[] fileContent, string fileDownloadName)
{
WriteFileToResponseInternal(response, fileContent, fileDownloadName, null, null);
}
/// <summary>
/// Writes the stream to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileStream">The stream to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
/// <param name="isAttachment"><c>true</c> if file is attachment; <c>false</c> if file is inline.</param>
/// <param name="contentType">The content type to use for the response.</param>
public static void WriteFileToResponse(HttpResponse response, Stream fileStream, string fileDownloadName, bool isAttachment, string contentType)
{
WriteFileToResponseInternal(response, fileStream, fileDownloadName, isAttachment, contentType);
}
/// <summary>
/// Writes the stream to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileStream">The stream to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
/// <param name="isAttachment"><c>true</c> if file is attachment; <c>false</c> if file is inline.</param>
public static void WriteFileToResponse(HttpResponse response, Stream fileStream, string fileDownloadName, bool isAttachment)
{
WriteFileToResponseInternal(response, fileStream, fileDownloadName, isAttachment, null);
}
/// <summary>
/// Writes the stream to the response.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="fileStream">The stream to send to the response.</param>
/// <param name="fileDownloadName">Suggested file name for download.</param>
public static void WriteFileToResponse(HttpResponse response, Stream fileStream, string fileDownloadName)
{
WriteFileToResponseInternal(response, fileStream, fileDownloadName, null, null);
}
/// <summary>
/// Gets string with MIME protocol Content-Disposition header.
/// </summary>
/// <param name="fileDownloadName">Suggested file name for download.</param>
/// <param name="isAttachment"><c>true</c> if file is attachment; <c>false</c> if file is inline.</param>
/// <returns>String with MIME protocol Content-Disposition header.</returns>
public static string GetContentDispositionHeader(string fileDownloadName, bool isAttachment)
{
if (fileDownloadName == null)
{
throw new ArgumentNullException("fileDownloadName");
}
foreach (char ch in fileDownloadName)
{
if (ch > '\x007f')
{
//RFC 2231 scheme is needed if the filename contains non-ASCII characters
return CreateRfc2231HeaderValue(fileDownloadName, isAttachment);
}
}
//Use RFC 2184 (http://www.apps.ietf.org/rfc/rfc2183.html).
//The filename will be escaped if, for example, the filename has a space in it then either the whole filename should be quoted (and quotes in the string escaped).
var disposition = new System.Net.Mime.ContentDisposition() { FileName = fileDownloadName, Inline = !isAttachment };
return disposition.ToString();
}
/// <summary>
/// Gets string with the HTTP MIME type for file eg. "text/html".
/// </summary>
/// <param name="extension">File extension.</param>
/// <returns>String with the HTTP MIME type eg. "text/html".</returns>
public static string GetContentType(string extension)
{
if (extension == null)
{
throw new ArgumentNullException("extension");
}
switch (extension.ToLowerInvariant())
{
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".gif":
return "image/gif";
case ".bmp":
return "image/bmp";
case ".txt":
return "text/plain";
case ".htm":
case ".html":
return "text/html";
case ".css":
return "text/css";
case ".xml":
return "text/xml";
case ".doc":
case ".dot":
return "application/msword";
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case ".dotx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.template";
case ".xls":
case ".xlt":
return "application/vnd.ms-excel";
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case ".xltx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.template";
case ".ppt":
return "application/vnd.ms-powerpoint";
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case ".pdf":
return "application/pdf";
case ".zip":
return "application/x-zip-compressed";
case ".gz":
return "application/x-gzip";
case ".z":
return "application/x-compress";
case ".rar":
case ".arj":
return "application/x-compressed";
case ".ics":
return "text/v-calendar";
}
return "application/octet-stream ";
}
#endregion
#region private member functions
private static void WriteFileToResponseInternal(HttpResponse response, byte[] fileContent, string fileDownloadName, bool? isAttachment, string contentType)
{
if (response == null)
{
throw new ArgumentNullException("response");
}
if (fileContent == null)
{
throw new ArgumentNullException("fileContent");
}
if (fileDownloadName == null)
{
throw new ArgumentNullException("fileDownloadName");
}
if (isAttachment == null)
{
isAttachment = IsAttachment(new FileInfo(fileDownloadName).Extension);
}
if (contentType == null)
{
contentType = GetContentType(new FileInfo(fileDownloadName).Extension);
}
response.AppendHeader("content-disposition", GetContentDispositionHeader(fileDownloadName, isAttachment.Value));
response.ContentType = contentType;
try
{
response.OutputStream.Write(fileContent, 0, fileContent.Length);
}
finally
{
response.Flush();
}
}
private static void WriteFileToResponseInternal(HttpResponse response, Stream fileStream, string fileDownloadName, bool? isAttachment, string contentType)
{
if (response == null)
{
throw new ArgumentNullException("response");
}
if (fileStream == null)
{
throw new ArgumentNullException("fileStream");
}
if (fileDownloadName == null)
{
throw new ArgumentNullException("fileDownloadName");
}
if (isAttachment == null)
{
isAttachment = IsAttachment(new FileInfo(fileDownloadName).Extension);
}
if (contentType == null)
{
contentType = GetContentType(new FileInfo(fileDownloadName).Extension);
}
response.AppendHeader("content-disposition", GetContentDispositionHeader(fileDownloadName, isAttachment.Value));
response.ContentType = contentType;
try
{
using (fileStream)
{
fileStream.CopyTo(response.OutputStream);
}
}
finally
{
response.Flush();
}
}
private static bool IsAttachment(string extension)
{
switch (extension.ToLowerInvariant())
{
case ".jpg":
case ".jpeg":
return false;
case ".png":
return false;
case ".gif":
return false;
case ".bmp":
return false;
case ".txt":
return false;
case ".htm":
case ".html":
return false;
case ".css":
return false;
}
return true;
}
private static string CreateRfc2231HeaderValue(string filename, bool isAttachment)
{
//Returns the entire filename encoded using the scheme specified in RFC 2231 (http://www.apps.ietf.org/rfc/rfc2231.html), which uses %20 to represent a space.
//eg. Content-Disposition: attachment;filename*=UTF-8''Q1%20Report.pdf
var sb = new System.Text.StringBuilder((isAttachment ? "attachment;" : "") + "filename*=UTF-8''");
foreach (byte num in System.Text.Encoding.UTF8.GetBytes(filename))
{
if (IsByteValidHeaderValueCharacter(num))
{
sb.Append((char)num);
}
else
{
sb.Append('%');
sb.Append(HexDigits[num >> 4]);
sb.Append(HexDigits[num % 0x10]);
}
}
return sb.ToString();
}
private static bool IsByteValidHeaderValueCharacter(byte num)
{
if ((0x30 <= num) && (num <= 0x39))
{
return true;
}
if ((0x61 <= num) && (num <= 0x7a))
{
return true;
}
if ((0x41 <= num) && (num <= 0x5a))
{
return true;
}
switch (num)
{
case 0x3a:
case 0x5f:
case 0x7e:
case 0x24:
case 0x26:
case 0x21:
case 0x2b:
case 0x2d:
case 0x2e:
return true;
}
return false;
}
#endregion
}