import { extname, join, ms, normalize, resolve, sep } from "../../deps.ts";import type { OpineRequest, OpineResponse } from "../types.ts";import { createError } from "../utils/createError.ts";import { parseHttpDate, parseTokenList } from "./fresh.ts";
const BYTES_RANGE_REGEXP = /^ *bytes=/;
const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
const ENOENT_REGEXP = /\(os error 2\)/;const ENAMETOOLONG_REGEXP = /\(os error 63\)|\(os error 36\)|\(os error 123\)/;
function normalizeList( value: false | string | string[], name: string,): string[] { const list = value === false ? [] : Array.isArray(value) ? value : [value];
for (let i = 0; i < list.length; i++) { if (typeof list[i] !== "string") { throw new TypeError(name + " must be array of strings or false"); } }
return list;}
function containsDotFile(parts: string[]): boolean { for (let i = 0; i < parts.length; i++) { const part = parts[i];
if (part.length > 1 && part[0] === ".") { return true; } }
return false;}
export function hasTrailingSlash(path: string): boolean { return path[path.length - 1] === sep;}
function isConditionalGET(req: OpineRequest): boolean { return Boolean( req.headers.get("if-match") || req.headers.get("if-unmodified-since") || req.headers.get("if-none-match") || req.headers.get("if-modified-since"), );}
function isPreconditionFailure(req: OpineRequest, res: OpineResponse): boolean { const match = req.headers.get("if-match");
if (match) { const etag = res.get("ETag");
return !etag || (match !== "*" && parseTokenList(match).every(function (match) { return match !== etag && match !== "W/" + etag && "W/" + match !== etag; })); }
const unmodifiedSince = parseHttpDate(req.get("if-unmodified-since"));
if (!isNaN(unmodifiedSince)) { const lastModified = parseHttpDate(res.get("Last-Modified"));
return isNaN(lastModified) || lastModified > unmodifiedSince; }
return false;}
function contentRange(type: string, size: number, range?: any): string { return type + " " + (range ? range.start + "-" + range.end : "*") + "/" + size;}
function removeContentHeaderFields(res: OpineResponse): void { const headers: string[] = Array.from(res.headers?.keys() ?? []);
for (const header of headers) { if (header.substr(0, 8) === "content-" && header !== "content-location") { res.unset(header); } }}
function isCacheable(statusCode: number): boolean { return (statusCode >= 200 && statusCode < 300) || statusCode === 304;}
function isRangeFresh(req: OpineRequest, res: OpineResponse): boolean { const ifRange = req.get("if-range");
if (!ifRange) { return true; }
if (ifRange.indexOf('"') !== -1) { const etag = res.get("ETag");
return Boolean(etag && ifRange.indexOf(etag) !== -1); }
const lastModified = res.get("Last-Modified");
return parseHttpDate(lastModified) <= parseHttpDate(ifRange);}
function collapseLeadingSlashes(str: string) { let i = 0;
for (; i < str.length; i++) { if (str[i] !== "/") { break; } }
return i > 1 ? "/" + str.substr(i) : str;}
function clearHeaders(res: OpineResponse) { const headers = Array.from(res.headers?.keys() ?? []);
for (const header of headers) { res.unset(header); }}
function create404Error(): Error { const error: any = new Deno.errors.NotFound(); error.status = 404; error.statusCode = 404;
return error;}
export function sendError(res: OpineResponse, error?: any): void { clearHeaders(res);
if (error?.headers) { res.set(error.headers); }
if (!error) { throw createError( create404Error(), { code: "ENOENT" }, ); } else if (ENOENT_REGEXP.test(error.message)) { throw createError(create404Error(), { ...error, code: "ENOENT" }); } else if (error instanceof Deno.errors.NotFound) { throw createError(create404Error(), { ...error, code: "ENOENT" }); } else if (ENAMETOOLONG_REGEXP.test(error.message)) { throw createError(create404Error(), { ...error, code: "ENAMETOOLONG" }); } else if (error.status === 404 || error.statusCode === 404) { throw createError(create404Error(), { ...error }); }
throw createError( error.status ?? error.statusCode ?? 500, error.message, { ...error }, );}
async function offsetFileReader( file: Deno.FsFile, offset: number, contentLength: number,): Promise<Deno.Reader & Deno.Closer> { let totalRead = 0; let finished = false;
await file.seek(offset, Deno.SeekMode.Start);
async function read(buf: Uint8Array): Promise<number | null> { if (finished) return null;
let result: number | null; const remaining = contentLength - totalRead;
if (remaining >= buf.byteLength) { result = await file.read(buf); } else { const readBuf = buf.subarray(0, remaining); result = await file.read(readBuf); }
if (result !== null) { totalRead += result; }
finished = totalRead === contentLength;
return result; }
function close(): void { file.close(); }
return { read, close };}
async function _send( req: OpineRequest, res: OpineResponse, path: string, options: any, stat: Deno.FileInfo,) { if (res.written) { return sendError(res, createError(500, "Response already written")); }
if (options.before) { options.before(res, path, stat); }
const cacheControl = Boolean(options.cacheControl ?? true);
if (cacheControl && !res.get("Cache-Control")) { let maxage = options.maxAge ?? options.maxage; maxage = typeof maxage === "string" ? ms(maxage) : Number(maxage); maxage = !isNaN(maxage) ? Math.min(Math.max(0, maxage), MAX_MAXAGE) : 0;
let cacheControlHeader = "public, max-age=" + Math.floor(maxage / 1000);
const immutable = Boolean(options.immutable ?? false);
if (immutable) { cacheControlHeader += ", immutable"; }
res.set("Cache-Control", cacheControlHeader); }
const lastModified = Boolean(options.lastModified ?? true);
if (lastModified && !res.get("Last-Modified") && stat.mtime) { res.set("Last-Modified", stat.mtime.toUTCString()); }
const etag = Boolean(options.etag ?? true);
if (etag && !res.get("ETag")) { res.etag(stat); }
if (!res.get("Content-Type")) { res.type(extname(path)); }
const acceptRanges = Boolean(options.acceptRanges ?? true);
if (acceptRanges && !res.get("Accept-Ranges")) { res.set("Accept-Ranges", "bytes"); }
if (isConditionalGET(req)) { if (isPreconditionFailure(req, res)) { return sendError(res, createError(412)); }
if (isCacheable(res.status as number) && req.fresh) { removeContentHeaderFields(res); res.status = 304;
return await res.end(); } }
let offset: number = options.start ?? 0; let len = Math.max(0, stat.size - offset);
if (options.end !== undefined) { const bytes = options.end - offset + 1;
if (len > bytes) { len = bytes; } }
const rangeHeader = req.headers.get("range") as string;
if (acceptRanges && BYTES_RANGE_REGEXP.test(rangeHeader)) { let range = req.range(len, { combine: true, });
if (!isRangeFresh(req, res)) { range = -2; }
if (range === -1) { res.set("Content-Range", contentRange("bytes", len));
return sendError( res, createError(416, undefined, { headers: { "Content-Range": res.get("Content-Range") }, }), ); }
if (range !== -2 && range?.length === 1) { res.setStatus(206); res.set("Content-Range", contentRange("bytes", len, range[0]));
offset += range[0].start; len = range[0].end - range[0].start + 1; } }
res.set("Content-Length", len + "");
if (req.method === "HEAD") { return await res.end(); }
const file = await Deno.open(path, { read: true });
return await res.send(await offsetFileReader(file, offset, len));}
async function sendIndex( req: OpineRequest, res: OpineResponse, path: string, options: any, index: string[],) { let error: Error | undefined;
for (const i of index) { const pathUsingIndex = join(path, i);
try { const stat = await Deno.stat(pathUsingIndex);
if (!stat.isDirectory) { return await _send(req, res, pathUsingIndex, options, stat); } else if (options.onDirectory) { return options.onDirectory(); } } catch (err) { error = err; } }
return sendError(res, error);}
async function sendExtension( req: OpineRequest, res: OpineResponse, path: string, options: any,) { let error: Error | undefined;
const extensions = options.extensions !== undefined ? normalizeList(options.extensions, "extensions option") : [];
for (const extension of extensions) { const pathUsingExtension = `${path}.${extension}`;
try { const stat = await Deno.stat(pathUsingExtension);
if (!stat.isDirectory) { return await _send(req, res, pathUsingExtension, options, stat); } else if (options.onDirectory) { return options.onDirectory(); } } catch (err) { error = err; } }
return sendError(res, error);}
async function sendFile( req: OpineRequest, res: OpineResponse, path: string, options: any,) { try { const stat = await Deno.stat(path);
if (!stat.isDirectory) { return await _send(req, res, path, options, stat); } else if (options.onDirectory) { return options.onDirectory(); }
if (hasTrailingSlash(path)) { return sendError(res, createError(403)); }
res.set("Content-Type", "text/html; charset=UTF-8"); res.set("Content-Security-Policy", "default-src 'none'"); res.set("X-Content-Type-Options", "nosniff");
return res.redirect( 301, encodeUrl(collapseLeadingSlashes(options.path + "/")), ); } catch (err) { if ( ENOENT_REGEXP.test(err.message) && !extname(path) && path[path.length - 1] !== sep ) { return await sendExtension(req, res, path, options); }
return sendError(res, err); }}
function decode(path: string) { try { return decodeURIComponent(path); } catch (_err) { return -1; }}
const ENCODE_CHARS_REGEXP = /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g;
const UNMATCHED_SURROGATE_PAIR_REGEXP = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g;
const UNMATCHED_SURROGATE_PAIR_REPLACE = "$1\uFFFD$2";
function encodeUrl(url: string) { return String(url) .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) .replace(ENCODE_CHARS_REGEXP, encodeURI);}
export async function send<T = OpineResponse<any>>( req: OpineRequest, res: T, path: string, options: any,): Promise<T | void>;export async function send( req: OpineRequest, res: OpineResponse, path: string, options: any,) { options.path = path;
const decodedPath = decode(path);
if (decodedPath === -1) { return sendError(res, createError(400)); }
path = decodedPath;
if (~path.indexOf("\0")) { return sendError(res, createError(400)); }
const root = options.root ? resolve(options.root) : null;
let parts;
if (root !== null) { if (path) { path = normalize("." + sep + path); }
if (UP_PATH_REGEXP.test(path)) { return sendError(res, createError(403)); }
parts = path.split(sep);
path = normalize(join(root, path)); } else { if (UP_PATH_REGEXP.test(path)) { return sendError(res, createError(403)); }
parts = normalize(path).split(sep);
path = normalize(path); }
if (containsDotFile(parts)) { const dotfiles = options.dotfiles ?? "ignore";
if (dotfiles !== "ignore" && dotfiles !== "allow" && dotfiles !== "deny") { return sendError( res, new TypeError( 'dotfiles option must be "allow", "deny", or "ignore"', ), ); }
switch (dotfiles) { case "allow": break; case "deny": return sendError(res, createError(403)); case "ignore": default: return sendError(res, createError(404)); } }
const index = options.index !== undefined ? normalizeList(options.index, "index option") : ["index.html"];
if (index.length && hasTrailingSlash(path)) { return await sendIndex(req, res, path, options, index); }
return await sendFile(req, res, path, options);}