Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add range request and etag support to file_server.ts #1028

Merged
merged 14 commits into from
Jul 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 220 additions & 8 deletions http/file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./server.ts";
import { parse } from "../flags/mod.ts";
import { assert } from "../_util/assert.ts";
import { readRange } from "../io/util.ts";

interface EntryInfo {
mode: string;
Expand Down Expand Up @@ -67,14 +68,137 @@ const MEDIA_TYPES: Record<string, string> = {
".css": "text/css",
".wasm": "application/wasm",
".mjs": "application/javascript",
".otf": "font/otf",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
".conf": "text/plain",
".list": "textplain",
".log": "text/plain",
".ini": "text/plain",
".vtt": "text/vtt",
".yaml": "text/yaml",
".yml": "text/yaml",
".mid": "audio/midi",
".midi": "audio/midi",
".mp3": "audio/mp3",
".mp4a": "audio/mp4",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".spx": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "audio/webm",
".aac": "audio/x-aac",
".flac": "audio/x-flac",
".mp4": "video/mp4",
".mp4v": "video/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".svg": "image/svg+xml",
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".heic": "image/heic",
".heif": "image/heif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".tiff": "image/tiff",
".psd": "image/vnd.adobe.photoshop",
".ico": "image/vnd.microsoft.icon",
".webp": "image/webp",
".es": "application/ecmascript",
".epub": "application/epub+zip",
".jar": "application/java-archive",
".war": "application/java-archive",
".webmanifest": "application/manifest+json",
".doc": "application/msword",
".dot": "application/msword",
".docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".dotx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
".cjs": "application/node",
".bin": "application/octet-stream",
".pkg": "application/octet-stream",
".dump": "application/octet-stream",
".exe": "application/octet-stream",
".deploy": "application/octet-stream",
".img": "application/octet-stream",
".msi": "application/octet-stream",
".pdf": "application/pdf",
".pgp": "application/pgp-encrypted",
".asc": "application/pgp-signature",
".sig": "application/pgp-signature",
".ai": "application/postscript",
".eps": "application/postscript",
".ps": "application/postscript",
".rdf": "application/rdf+xml",
".rss": "application/rss+xml",
".rtf": "application/rtf",
".apk": "application/vnd.android.package-archive",
".key": "application/vnd.apple.keynote",
".numbers": "application/vnd.apple.keynote",
".pages": "application/vnd.apple.pages",
".geo": "application/vnd.dynageo",
".gdoc": "application/vnd.google-apps.document",
".gslides": "application/vnd.google-apps.presentation",
".gsheet": "application/vnd.google-apps.spreadsheet",
".kml": "application/vnd.google-earth.kml+xml",
".mkz": "application/vnd.google-earth.kmz",
".icc": "application/vnd.iccprofile",
".icm": "application/vnd.iccprofile",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlm": "application/vnd.ms-excel",
".ppt": "application/vnd.ms-powerpoint",
".pot": "application/vnd.ms-powerpoint",
".pptx":
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
".potx":
"application/vnd.openxmlformats-officedocument.presentationml.template",
".xps": "application/vnd.ms-xpsdocument",
".odc": "application/vnd.oasis.opendocument.chart",
".odb": "application/vnd.oasis.opendocument.database",
".odf": "application/vnd.oasis.opendocument.formula",
".odg": "application/vnd.oasis.opendocument.graphics",
".odp": "application/vnd.oasis.opendocument.presentation",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odt": "application/vnd.oasis.opendocument.text",
".rar": "application/vnd.rar",
".unityweb": "application/vnd.unity",
".dmg": "application/x-apple-diskimage",
".bz": "application/x-bzip",
".crx": "application/x-chrome-extension",
".deb": "application/x-debian-package",
".php": "application/x-httpd-php",
".iso": "application/x-iso9660-image",
".sh": "application/x-sh",
".sql": "application/x-sql",
".srt": "application/x-subrip",
".xml": "application/xml",
".zip": "application/zip",
};

/** Returns the content-type based on the extension of a path. */
function contentType(path: string): string | undefined {
return MEDIA_TYPES[extname(path)];
}

// Generates a SHA-1 hash for the provided string
async function createEtagHash(message: string) {
const byteToHex = (b: number) => b.toString(16).padStart(2, "00");
const hashType = "SHA-1"; // Faster, and this isn't a security senitive cryptographic use case

// see: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest(hashType, msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(byteToHex).join("");
return hashHex;
}

function modeToString(isDir: boolean, maybeMode: number | null): string {
const modeMap = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];

Expand Down Expand Up @@ -127,20 +251,97 @@ export async function serveFile(
Deno.open(filePath),
Deno.stat(filePath),
]);
const headers = new Headers();
headers.set("content-length", fileInfo.size.toString());

const headers = setBaseHeaders();

// Base response
const response = {
status: 200,
statusText: "OK",
body: new Uint8Array(),
headers,
};

// Set mime-type using the file extension in filePath
const contentTypeValue = contentType(filePath);
if (contentTypeValue) {
headers.set("content-type", contentTypeValue);
}

// Set date header if access timestamp is available
if (fileInfo.atime instanceof Date) {
const date = new Date(fileInfo.atime);
headers.set("date", date.toUTCString());
}

// Set last modified header if access timestamp is available
if (fileInfo.mtime instanceof Date) {
const lastModified = new Date(fileInfo.mtime);
headers.set("last-modified", lastModified.toUTCString());

// Create a simple etag that is an md5 of the last modified date and filesize concatenated
const simpleEtag = await createEtagHash(
`${lastModified.toJSON()}${fileInfo.size}`,
);
headers.set("etag", simpleEtag);

// If a `if-node-match` header is present and the value matches the tag return 304
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === simpleEtag) {
response.status = 304;
response.statusText = "Not Modified";
return response;
}
}

// Get and parse the "range" header
const range = req.headers.get("range") as string;
const rangeRe = /bytes=(\d+)-(\d+)?/;
const parsed = rangeRe.exec(range);

// Use the parsed value if available, fallback to the start and end of the entire file
const start = parsed && parsed[1] ? +parsed[1] : 0;
const end = parsed && parsed[2] ? +parsed[2] : Math.max(0, fileInfo.size - 1);

// If there is a range, set the status to 206, and set the "Content-range" header.
if (range && parsed) {
response.status = 206;
response.statusText = "Partial Content";
headers.set("content-range", `bytes ${start}-${end}/${fileInfo.size}`);
}

// Return 416 if `start` isn't less than or equal to `end`, or `start` or `end` are greater than the file's size
const maxRange =
(typeof fileInfo.size === "number" ? Math.max(0, fileInfo.size - 1) : 0);

if (
range && !parsed ||
(typeof start !== "number" || start > end || start > maxRange ||
end > maxRange)
) {
response.status = 416;
response.statusText = "Range Not Satisfiable";
response.body = encoder.encode("Range Not Satisfiable");
return response;
}

try {
// Read the selected range of the file
const bytes = await readRange(file, { start, end });

// Set content length and response body
headers.set("content-length", bytes.length.toString());
response.body = bytes;
} catch (e) {
// Fallback on URIError (400 Bad Request) if unable to read range
throw URIError(e);
}

req.done.then(() => {
file.close();
});
return {
status: 200,
body: file,
headers,
};

return response;
}

// TODO(bartlomieju): simplify this after deno.stat and deno.readDir are fixed
Expand Down Expand Up @@ -188,7 +389,7 @@ async function serveDir(
const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`;
const page = encoder.encode(dirViewerTemplate(formattedDirUrl, listEntry));

const headers = new Headers();
const headers = setBaseHeaders();
headers.set("content-type", "text/html");

const res = {
Expand Down Expand Up @@ -225,6 +426,17 @@ function serverLog(req: ServerRequest, res: Response): void {
console.log(s);
}

function setBaseHeaders(): Headers {
const headers = new Headers();
headers.set("server", "deno");

// Set "accept-ranges" so that the client knows it can make range requests on future requests
headers.set("accept-ranges", "bytes");
headers.set("date", new Date().toUTCString());

return headers;
}

function setCORS(res: Response): void {
if (!res.headers) {
res.headers = new Headers();
Expand Down
Loading