import { extname, posix } from "../path/mod.ts";import { encode } from "../encoding/hex.ts";import { contentType } from "../media_types/mod.ts";import { serve, serveTls } from "./server.ts";import { Status, STATUS_TEXT } from "./http_status.ts";import { parse } from "../flags/mod.ts";import { assert } from "../_util/assert.ts";import { red } from "../fmt/colors.ts";import { compareEtag } from "./util.ts";
const DEFAULT_CHUNK_SIZE = 16_640;
interface EntryInfo { mode: string; size: string; url: string; name: string;}
const encoder = new TextEncoder();const decoder = new TextDecoder();
function fnv1a(buf: string): string { let hash = 2166136261; for (let i = 0; i < buf.length; i++) { hash ^= buf.charCodeAt(i); hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); } return (hash >>> 0).toString(16);}
export type EtagAlgorithm = | "fnv1a" | "sha-1" | "sha-256" | "sha-384" | "sha-512";
async function createEtagHash( message: string, algorithm: EtagAlgorithm = "fnv1a",): Promise<string> { if (algorithm === "fnv1a") { return fnv1a(message); } const msgUint8 = encoder.encode(message); const hashBuffer = await crypto.subtle.digest(algorithm, msgUint8); return decoder.decode(encode(new Uint8Array(hashBuffer)));}
function modeToString(isDir: boolean, maybeMode: number | null): string { const modeMap = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
if (maybeMode === null) { return "(unknown mode)"; } const mode = maybeMode.toString(8); if (mode.length < 3) { return "(unknown mode)"; } let output = ""; mode .split("") .reverse() .slice(0, 3) .forEach((v): void => { output = `${modeMap[+v]} ${output}`; }); output = `${isDir ? "d" : "-"} ${output}`; return output;}
function fileLenToString(len: number): string { const multiplier = 1024; let base = 1; const suffix = ["B", "K", "M", "G", "T"]; let suffixIndex = 0;
while (base * multiplier < len) { if (suffixIndex >= suffix.length - 1) { break; } base *= multiplier; suffixIndex++; }
return `${(len / base).toFixed(2)}${suffix[suffixIndex]}`;}
export interface ServeFileOptions { etagAlgorithm?: EtagAlgorithm; fileInfo?: Deno.FileInfo;}
export async function serveFile( req: Request, filePath: string, { etagAlgorithm, fileInfo }: ServeFileOptions = {},): Promise<Response> { let file: Deno.FsFile; if (fileInfo === undefined) { [file, fileInfo] = await Promise.all([ Deno.open(filePath), Deno.stat(filePath), ]); } else { file = await Deno.open(filePath); } const headers = setBaseHeaders();
const contentTypeValue = contentType(extname(filePath)); if (contentTypeValue) { headers.set("content-type", contentTypeValue); }
if (fileInfo.atime instanceof Date) { const date = new Date(fileInfo.atime); headers.set("date", date.toUTCString()); }
if (fileInfo.mtime instanceof Date) { const lastModified = new Date(fileInfo.mtime); headers.set("last-modified", lastModified.toUTCString());
const simpleEtag = await createEtagHash( `${lastModified.toJSON()}${fileInfo.size}`, etagAlgorithm || "fnv1a", ); headers.set("etag", simpleEtag);
const ifNoneMatch = req.headers.get("if-none-match"); const ifModifiedSince = req.headers.get("if-modified-since"); if ( (ifNoneMatch && compareEtag(ifNoneMatch, simpleEtag)) || (ifNoneMatch === null && ifModifiedSince && fileInfo.mtime.getTime() < new Date(ifModifiedSince).getTime() + 1000) ) { const status = Status.NotModified; const statusText = STATUS_TEXT[status];
file.close();
return new Response(null, { status, statusText, headers, }); } }
const range = req.headers.get("range") as string; const rangeRe = /bytes=(\d+)-(\d+)?/; const parsed = rangeRe.exec(range);
const start = parsed && parsed[1] ? +parsed[1] : 0; const end = parsed && parsed[2] ? +parsed[2] : fileInfo.size - 1;
if (range && parsed) { headers.set("content-range", `bytes ${start}-${end}/${fileInfo.size}`); }
const maxRange = fileInfo.size - 1;
if ( range && (!parsed || typeof start !== "number" || start > end || start > maxRange || end > maxRange) ) { const status = Status.RequestedRangeNotSatisfiable; const statusText = STATUS_TEXT[status];
file.close();
return new Response(statusText, { status, statusText, headers, }); }
const contentLength = end - start + 1; headers.set("content-length", `${contentLength}`); if (range && parsed) { let bytesSent = 0; const body = new ReadableStream({ async start() { if (start > 0) { await file.seek(start, Deno.SeekMode.Start); } }, async pull(controller) { const bytes = new Uint8Array(DEFAULT_CHUNK_SIZE); const bytesRead = await file.read(bytes); if (bytesRead === null) { file.close(); controller.close(); return; } controller.enqueue( bytes.slice(0, Math.min(bytesRead, contentLength - bytesSent)), ); bytesSent += bytesRead; if (bytesSent > contentLength) { file.close(); controller.close(); } }, });
return new Response(body, { status: 206, statusText: "Partial Content", headers, }); }
return new Response(file.readable, { status: 200, statusText: "OK", headers, });}
async function serveDirIndex( req: Request, dirPath: string, options: { dotfiles: boolean; target: string; etagAlgorithm?: EtagAlgorithm; },): Promise<Response> { const showDotfiles = options.dotfiles; const dirUrl = `/${posix.relative(options.target, dirPath)}`; const listEntry: EntryInfo[] = [];
if (dirUrl !== "/") { const prevPath = posix.join(dirPath, ".."); const fileInfo = await Deno.stat(prevPath); listEntry.push({ mode: modeToString(true, fileInfo.mode), size: "", name: "../", url: posix.join(dirUrl, ".."), }); }
for await (const entry of Deno.readDir(dirPath)) { if (!showDotfiles && entry.name[0] === ".") { continue; } const filePath = posix.join(dirPath, entry.name); const fileUrl = encodeURI(posix.join(dirUrl, entry.name)); const fileInfo = await Deno.stat(filePath); if (entry.name === "index.html" && entry.isFile) { return serveFile(req, filePath, { etagAlgorithm: options.etagAlgorithm, fileInfo, }); } listEntry.push({ mode: modeToString(entry.isDirectory, fileInfo.mode), size: entry.isFile ? fileLenToString(fileInfo.size ?? 0) : "", name: `${entry.name}${entry.isDirectory ? "/" : ""}`, url: `${fileUrl}${entry.isDirectory ? "/" : ""}`, }); } listEntry.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 ); const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`; const page = encoder.encode(dirViewerTemplate(formattedDirUrl, listEntry));
const headers = setBaseHeaders(); headers.set("content-type", "text/html");
return new Response(page, { status: Status.OK, headers });}
function serveFallback(_req: Request, e: Error): Promise<Response> { if (e instanceof URIError) { return Promise.resolve( new Response(STATUS_TEXT[Status.BadRequest], { status: Status.BadRequest, statusText: STATUS_TEXT[Status.BadRequest], }), ); } else if (e instanceof Deno.errors.NotFound) { return Promise.resolve( new Response(STATUS_TEXT[Status.NotFound], { status: Status.NotFound, statusText: STATUS_TEXT[Status.NotFound], }), ); }
return Promise.resolve( new Response(STATUS_TEXT[Status.InternalServerError], { status: Status.InternalServerError, statusText: STATUS_TEXT[Status.InternalServerError], }), );}
function serverLog(req: Request, status: number): void { const d = new Date().toISOString(); const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`; const normalizedUrl = normalizeURL(req.url); const s = `${dateFmt} [${req.method}] ${normalizedUrl} ${status}`; console.debug(s);}
function setBaseHeaders(): Headers { const headers = new Headers(); headers.set("server", "deno");
headers.set("accept-ranges", "bytes"); headers.set("date", new Date().toUTCString());
return headers;}
function dirViewerTemplate(dirname: string, entries: EntryInfo[]): string { const paths = dirname.split("/");
return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Deno File Server</title> <style> :root { --background-color: #fafafa; --color: rgba(0, 0, 0, 0.87); } @media (prefers-color-scheme: dark) { :root { --background-color: #292929; --color: #fff; } thead { color: #7f7f7f; } } @media (min-width: 960px) { main { max-width: 960px; } body { padding-left: 32px; padding-right: 32px; } } @media (min-width: 600px) { main { padding-left: 24px; padding-right: 24px; } } body { background: var(--background-color); color: var(--color); font-family: "Roboto", "Helvetica", "Arial", sans-serif; font-weight: 400; line-height: 1.43; font-size: 0.875rem; } a { color: #2196f3; text-decoration: none; } a:hover { text-decoration: underline; } thead { text-align: left; } thead th { padding-bottom: 12px; } table td { padding: 6px 36px 6px 0px; } .size { text-align: right; padding: 6px 12px 6px 24px; } .mode { font-family: monospace, monospace; } </style> </head> <body> <main> <h1>Index of <a href="/">home</a>${ paths .map((path, index, array) => { if (path === "") return ""; const link = array.slice(0, index + 1).join("/"); return `<a href="${link}">${path}</a>`; }) .join("/") } </h1> <table> <thead> <tr> <th>Mode</th> <th>Size</th> <th>Name</th> </tr> </thead> ${ entries .map( (entry) => ` <tr> <td class="mode"> ${entry.mode} </td> <td class="size"> ${entry.size} </td> <td> <a href="${entry.url}">${entry.name}</a> </td> </tr> `, ) .join("") } </table> </main> </body> </html> `;}
export interface ServeDirOptions { fsRoot?: string; urlRoot?: string; showDirListing?: boolean; showDotfiles?: boolean; enableCors?: boolean; quiet?: boolean; etagAlgorithm?: EtagAlgorithm;}
export async function serveDir(req: Request, opts: ServeDirOptions = {}) { let response: Response; const target = opts.fsRoot || "."; const urlRoot = opts.urlRoot;
try { let normalizedPath = normalizeURL(req.url); if (urlRoot) { if (normalizedPath.startsWith("/" + urlRoot)) { normalizedPath = normalizedPath.replace(urlRoot, ""); } else { throw new Deno.errors.NotFound(); } }
const fsPath = posix.join(target, normalizedPath); const fileInfo = await Deno.stat(fsPath);
if (fileInfo.isDirectory) { if (opts.showDirListing) { response = await serveDirIndex(req, fsPath, { dotfiles: opts.showDotfiles || false, target, }); } else { throw new Deno.errors.NotFound(); } } else { response = await serveFile(req, fsPath, { etagAlgorithm: opts.etagAlgorithm, fileInfo, }); } } catch (e) { const err = e instanceof Error ? e : new Error("[non-error thrown]"); console.error(red(err.message)); response = await serveFallback(req, err); }
if (opts.enableCors) { assert(response); response.headers.append("access-control-allow-origin", "*"); response.headers.append( "access-control-allow-headers", "Origin, X-Requested-With, Content-Type, Accept, Range", ); }
if (!opts.quiet) serverLog(req, response!.status);
return response!;}
function normalizeURL(url: string): string { let normalizedUrl = url;
try { const absoluteURI = new URL(normalizedUrl); normalizedUrl = absoluteURI.pathname; } catch (e) { if (!(e instanceof TypeError)) { throw e; } }
try { normalizedUrl = decodeURI(normalizedUrl); } catch (e) { if (!(e instanceof URIError)) { throw e; } }
if (normalizedUrl[0] !== "/") { throw new URIError("The request URI is malformed."); }
normalizedUrl = posix.normalize(normalizedUrl); const startOfParams = normalizedUrl.indexOf("?");
return startOfParams > -1 ? normalizedUrl.slice(0, startOfParams) : normalizedUrl;}
function main(): void { const serverArgs = parse(Deno.args, { string: ["port", "host", "cert", "key"], boolean: ["help", "dir-listing", "dotfiles", "cors", "verbose"], negatable: ["dir-listing", "dotfiles", "cors"], default: { "dir-listing": true, dotfiles: true, cors: true, verbose: false, host: "0.0.0.0", port: "4507", cert: "", key: "", }, alias: { p: "port", c: "cert", k: "key", h: "help", v: "verbose", }, }); const port = serverArgs.port; const host = serverArgs.host; const certFile = serverArgs.cert; const keyFile = serverArgs.key;
if (serverArgs.help) { printUsage(); Deno.exit(); }
if (keyFile || certFile) { if (keyFile === "" || certFile === "") { console.log("--key and --cert are required for TLS"); printUsage(); Deno.exit(1); } }
const wild = serverArgs._ as string[]; const target = posix.resolve(wild[0] ?? "");
const handler = (req: Request): Promise<Response> => { return serveDir(req, { fsRoot: target, showDirListing: serverArgs["dir-listing"], showDotfiles: serverArgs.dotfiles, enableCors: serverArgs.cors, quiet: !serverArgs.verbose, }); };
const useTls = Boolean(keyFile || certFile);
if (useTls) { serveTls(handler, { port: Number(port), hostname: host, certFile, keyFile, }); } else { serve(handler, { port: Number(port), hostname: host }); }}
function printUsage() { console.log(`Deno File Server Serves a local directory in HTTP.
INSTALL: deno install --allow-net --allow-read https://deno.land/std/http/file_server.ts
USAGE: file_server [path] [options]
OPTIONS: -h, --help Prints help information -p, --port <PORT> Set port --cors Enable CORS via the "Access-Control-Allow-Origin" header --host <HOST> Hostname (default is 0.0.0.0) -c, --cert <FILE> TLS certificate file (enables TLS) -k, --key <FILE> TLS key file (enables TLS) --no-dir-listing Disable directory listing --no-dotfiles Do not show dotfiles --no-cors Disable cross-origin resource sharing -v, --verbose Print request level logs
All TLS options are required when one is provided.`);}
if (import.meta.main) { main();}