mirror of
https://github.com/denoland/deno.git
synced 2024-12-23 15:49:44 -05:00
28869a632d
Fix path traversal problem when the request URI does not have a leading slash. The file server now returns HTTP 400 when requests lack the leading slash, and are not absolute URIs. (https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html).
454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
#!/usr/bin/env -S deno run --allow-net --allow-read
|
|
// Copyright 2018-2020 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
|
|
|
|
import { extname, posix } from "../path/mod.ts";
|
|
import {
|
|
HTTPSOptions,
|
|
listenAndServe,
|
|
listenAndServeTLS,
|
|
Response,
|
|
ServerRequest,
|
|
} from "./server.ts";
|
|
import { parse } from "../flags/mod.ts";
|
|
import { assert } from "../_util/assert.ts";
|
|
|
|
interface EntryInfo {
|
|
mode: string;
|
|
size: string;
|
|
url: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface FileServerArgs {
|
|
_: string[];
|
|
// -p --port
|
|
p?: number;
|
|
port?: number;
|
|
// --cors
|
|
cors?: boolean;
|
|
// --no-dir-listing
|
|
"dir-listing"?: boolean;
|
|
// --host
|
|
host?: string;
|
|
// -c --cert
|
|
c?: string;
|
|
cert?: string;
|
|
// -k --key
|
|
k?: string;
|
|
key?: string;
|
|
// -h --help
|
|
h?: boolean;
|
|
help?: boolean;
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
const serverArgs = parse(Deno.args) as FileServerArgs;
|
|
const target = posix.resolve(serverArgs._[0] ?? "");
|
|
|
|
const MEDIA_TYPES: Record<string, string> = {
|
|
".md": "text/markdown",
|
|
".html": "text/html",
|
|
".htm": "text/html",
|
|
".json": "application/json",
|
|
".map": "application/json",
|
|
".txt": "text/plain",
|
|
".ts": "text/typescript",
|
|
".tsx": "text/tsx",
|
|
".js": "application/javascript",
|
|
".jsx": "text/jsx",
|
|
".gz": "application/gzip",
|
|
".css": "text/css",
|
|
".wasm": "application/wasm",
|
|
".mjs": "application/javascript",
|
|
};
|
|
|
|
/** Returns the content-type based on the extension of a path. */
|
|
function contentType(path: string): string | undefined {
|
|
return MEDIA_TYPES[extname(path)];
|
|
}
|
|
|
|
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]}`;
|
|
}
|
|
|
|
/**
|
|
* Returns an HTTP Response with the requested file as the body
|
|
* @param req The server request context used to cleanup the file handle
|
|
* @param filePath Path of the file to serve
|
|
*/
|
|
export async function serveFile(
|
|
req: ServerRequest,
|
|
filePath: string,
|
|
): Promise<Response> {
|
|
const [file, fileInfo] = await Promise.all([
|
|
Deno.open(filePath),
|
|
Deno.stat(filePath),
|
|
]);
|
|
const headers = new Headers();
|
|
headers.set("content-length", fileInfo.size.toString());
|
|
const contentTypeValue = contentType(filePath);
|
|
if (contentTypeValue) {
|
|
headers.set("content-type", contentTypeValue);
|
|
}
|
|
req.done.then(() => {
|
|
file.close();
|
|
});
|
|
return {
|
|
status: 200,
|
|
body: file,
|
|
headers,
|
|
};
|
|
}
|
|
|
|
// TODO: simplify this after deno.stat and deno.readDir are fixed
|
|
async function serveDir(
|
|
req: ServerRequest,
|
|
dirPath: string,
|
|
): Promise<Response> {
|
|
const dirUrl = `/${posix.relative(target, dirPath)}`;
|
|
const listEntry: EntryInfo[] = [];
|
|
for await (const entry of Deno.readDir(dirPath)) {
|
|
const filePath = posix.join(dirPath, entry.name);
|
|
const fileUrl = posix.join(dirUrl, entry.name);
|
|
if (entry.name === "index.html" && entry.isFile) {
|
|
// in case index.html as dir...
|
|
return serveFile(req, filePath);
|
|
}
|
|
// Yuck!
|
|
let fileInfo = null;
|
|
try {
|
|
fileInfo = await Deno.stat(filePath);
|
|
} catch (e) {
|
|
// Pass
|
|
}
|
|
listEntry.push({
|
|
mode: modeToString(entry.isDirectory, fileInfo?.mode ?? null),
|
|
size: entry.isFile ? fileLenToString(fileInfo?.size ?? 0) : "",
|
|
name: entry.name,
|
|
url: fileUrl,
|
|
});
|
|
}
|
|
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 = new Headers();
|
|
headers.set("content-type", "text/html");
|
|
|
|
const res = {
|
|
status: 200,
|
|
body: page,
|
|
headers,
|
|
};
|
|
return res;
|
|
}
|
|
|
|
function serveFallback(req: ServerRequest, e: Error): Promise<Response> {
|
|
if (e instanceof URIError) {
|
|
return Promise.resolve({
|
|
status: 400,
|
|
body: encoder.encode("Bad Request"),
|
|
});
|
|
} else if (e instanceof Deno.errors.NotFound) {
|
|
return Promise.resolve({
|
|
status: 404,
|
|
body: encoder.encode("Not Found"),
|
|
});
|
|
} else {
|
|
return Promise.resolve({
|
|
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",
|
|
);
|
|
}
|
|
|
|
function dirViewerTemplate(dirname: string, entries: EntryInfo[]): string {
|
|
return html`
|
|
<!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: #303030;
|
|
--color: #fff;
|
|
}
|
|
}
|
|
@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;
|
|
}
|
|
table th {
|
|
text-align: left;
|
|
}
|
|
table td {
|
|
padding: 12px 24px 0 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Index of ${dirname}</h1>
|
|
<table>
|
|
<tr>
|
|
<th>Mode</th>
|
|
<th>Size</th>
|
|
<th>Name</th>
|
|
</tr>
|
|
${
|
|
entries.map(
|
|
(entry) =>
|
|
html`
|
|
<tr>
|
|
<td class="mode">
|
|
${entry.mode}
|
|
</td>
|
|
<td>
|
|
${entry.size}
|
|
</td>
|
|
<td>
|
|
<a href="${entry.url}">${entry.name}</a>
|
|
</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
}
|
|
</table>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
|
|
const l = strings.length - 1;
|
|
let html = "";
|
|
|
|
for (let i = 0; i < l; i++) {
|
|
let v = values[i];
|
|
if (v instanceof Array) {
|
|
v = v.join("");
|
|
}
|
|
const s = strings[i] + v;
|
|
html += s;
|
|
}
|
|
html += strings[l];
|
|
return html;
|
|
}
|
|
|
|
function normalizeURL(url: string): string {
|
|
let normalizedUrl = url;
|
|
try {
|
|
normalizedUrl = decodeURI(normalizedUrl);
|
|
} catch (e) {
|
|
if (!(e instanceof URIError)) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
try {
|
|
//allowed per https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
|
|
const absoluteURI = new URL(normalizedUrl);
|
|
normalizedUrl = absoluteURI.pathname;
|
|
} catch (e) { //wasn't an absoluteURI
|
|
if (!(e instanceof TypeError)) {
|
|
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 CORSEnabled = serverArgs.cors ? true : false;
|
|
const port = serverArgs.port ?? serverArgs.p ?? 4507;
|
|
const host = serverArgs.host ?? "0.0.0.0";
|
|
const addr = `${host}:${port}`;
|
|
const tlsOpts = {} as HTTPSOptions;
|
|
tlsOpts.certFile = serverArgs.cert ?? serverArgs.c ?? "";
|
|
tlsOpts.keyFile = serverArgs.key ?? serverArgs.k ?? "";
|
|
const dirListingEnabled = serverArgs["dir-listing"] ?? true;
|
|
|
|
if (tlsOpts.keyFile || tlsOpts.certFile) {
|
|
if (tlsOpts.keyFile === "" || tlsOpts.certFile === "") {
|
|
console.log("--key and --cert are required for TLS");
|
|
serverArgs.h = true;
|
|
}
|
|
}
|
|
|
|
if (serverArgs.h ?? serverArgs.help) {
|
|
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
|
|
|
|
All TLS options are required when one is provided.`);
|
|
Deno.exit();
|
|
}
|
|
|
|
const handler = async (req: ServerRequest): Promise<void> => {
|
|
let response: Response | undefined;
|
|
try {
|
|
const normalizedUrl = normalizeURL(req.url);
|
|
let fsPath = posix.join(target, normalizedUrl);
|
|
if (fsPath.indexOf(target) !== 0) {
|
|
fsPath = target;
|
|
}
|
|
const fileInfo = await Deno.stat(fsPath);
|
|
if (fileInfo.isDirectory) {
|
|
if (dirListingEnabled) {
|
|
response = await serveDir(req, fsPath);
|
|
} else {
|
|
throw new Deno.errors.NotFound();
|
|
}
|
|
} else {
|
|
response = await serveFile(req, fsPath);
|
|
}
|
|
} catch (e) {
|
|
console.error(e.message);
|
|
response = await serveFallback(req, e);
|
|
} finally {
|
|
if (CORSEnabled) {
|
|
assert(response);
|
|
setCORS(response);
|
|
}
|
|
serverLog(req, response!);
|
|
try {
|
|
await req.respond(response!);
|
|
} catch (e) {
|
|
console.error(e.message);
|
|
}
|
|
}
|
|
};
|
|
|
|
let proto = "http";
|
|
if (tlsOpts.keyFile || tlsOpts.certFile) {
|
|
proto += "s";
|
|
tlsOpts.hostname = host;
|
|
tlsOpts.port = port;
|
|
listenAndServeTLS(tlsOpts, handler);
|
|
} else {
|
|
listenAndServe(addr, handler);
|
|
}
|
|
console.log(`${proto.toUpperCase()} server listening on ${proto}://${addr}/`);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main();
|
|
}
|