#!/usr/bin/env -S deno --allow-net // Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. // This program serves files in the current directory over HTTP. // TODO Stream responses instead of reading them into memory. // TODO Add tests like these: // https://github.com/indexzero/http-server/blob/master/test/http-server-test.js const { ErrorKind, cwd, args, stat, readDir, open } = Deno; import { contentType } from "../media_types/mod.ts"; import { extname, posix } from "../path/mod.ts"; import { listenAndServe, ServerRequest, setContentLength, Response } from "./server.ts"; const dirViewerTemplate = ` Deno File Server

Index of <%DIRNAME%>

<%CONTENTS%>
ModeSizeName
`; const serverArgs = args.slice(); let CORSEnabled = false; // TODO: switch to flags if we later want to add more options for (let i = 0; i < serverArgs.length; i++) { if (serverArgs[i] === "--cors") { CORSEnabled = true; serverArgs.splice(i, 1); break; } } const targetArg = serverArgs[1] || ""; const target = posix.isAbsolute(targetArg) ? posix.normalize(targetArg) : posix.join(cwd(), targetArg); const addr = `0.0.0.0:${serverArgs[2] || 4500}`; const encoder = new TextEncoder(); 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]}`; } function createDirEntryDisplay( name: string, url: string, size: number | null, mode: number | null, isDir: boolean ): string { const sizeStr = size === null ? "" : "" + fileLenToString(size!); return ` ${modeToString( isDir, mode )}${sizeStr}${name}${ isDir ? "/" : "" } `; } async function serveFile( req: ServerRequest, filePath: string ): Promise { const file = await open(filePath); const fileInfo = await stat(filePath); const headers = new Headers(); headers.set("content-length", fileInfo.len.toString()); headers.set("content-type", contentType(extname(filePath)) || "text/plain"); const res = { status: 200, body: file, headers }; return res; } // TODO: simplify this after deno.stat and deno.readDir are fixed async function serveDir( req: ServerRequest, dirPath: string ): Promise { interface ListItem { name: string; template: string; } const dirUrl = `/${posix.relative(target, dirPath)}`; const listEntry: ListItem[] = []; const fileInfos = await readDir(dirPath); for (const fileInfo of fileInfos) { const filePath = posix.join(dirPath, fileInfo.name); const fileUrl = posix.join(dirUrl, fileInfo.name); if (fileInfo.name === "index.html" && fileInfo.isFile()) { // in case index.html as dir... return await serveFile(req, filePath); } // Yuck! let mode = null; try { mode = (await stat(filePath)).mode; } catch (e) {} listEntry.push({ name: fileInfo.name, template: createDirEntryDisplay( fileInfo.name, fileUrl, fileInfo.isFile() ? fileInfo.len : null, mode, fileInfo.isDirectory() ) }); } const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`; const page = new TextEncoder().encode( dirViewerTemplate.replace("<%DIRNAME%>", formattedDirUrl).replace( "<%CONTENTS%>", listEntry .sort( (a, b): number => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 ) .map((v): string => v.template) .join("") ) ); const headers = new Headers(); headers.set("content-type", "text/html"); const res = { status: 200, body: page, headers }; setContentLength(res); return res; } async function serveFallback(req: ServerRequest, e: Error): Promise { if ( e instanceof Deno.DenoError && (e as Deno.DenoError).kind === ErrorKind.NotFound ) { return { status: 404, body: encoder.encode("Not found") }; } else { return { status: 500, body: encoder.encode("Internal server error") }; } } function serverLog(req: ServerRequest, res: Response): void { const d = new Date().toISOString(); const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`; const s = `${dateFmt} "${req.method} ${req.url} ${req.proto}" ${res.status}`; console.log(s); } function setCORS(res: Response): void { if (!res.headers) { res.headers = new Headers(); } res.headers!.append("access-control-allow-origin", "*"); res.headers!.append( "access-control-allow-headers", "Origin, X-Requested-With, Content-Type, Accept, Range" ); } listenAndServe( addr, async (req): Promise => { const normalizedUrl = posix.normalize(req.url); const fsPath = posix.join(target, normalizedUrl); let response: Response; try { const info = await stat(fsPath); if (info.isDirectory()) { response = await serveDir(req, fsPath); } else { response = await serveFile(req, fsPath); } } catch (e) { response = await serveFallback(req, e); } finally { if (CORSEnabled) { setCORS(response); } serverLog(req, response); req.respond(response); } } ); console.log(`HTTP server listening on http://${addr}/`);