serverus/handlers/html.ts

112 lines
3 KiB
TypeScript

/**
* Default handler for returning HTML with SSI support
* @module
*/
import * as path from '@std/path';
// https://stackoverflow.com/a/75205316
const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, ...args: any) => Promise<any>) => {
const promises: Promise<any>[] = [];
str.replace(regex, (match, ...args) => {
promises.push(asyncFn(match, ...args));
return match;
});
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift());
};
const SSI_REGEX: RegExp = /\<\!--\s+#include\s+(?<type>.*?)\s*=\s*["'](?<location>.*?)['"]\s+-->/mg;
async function load_html_with_ssi(html_file: string): Promise<string> {
const html_file_content: string = await Deno.readTextFile(html_file);
const processed: string = await replaceAsync(
html_file_content,
SSI_REGEX,
async (_match, type, location, index): Promise<string | undefined> => {
switch (type) {
case 'file': {
const directory = path.dirname(html_file);
const resolved = path.resolve(path.join(directory, location));
if (!resolved.startsWith(Deno.cwd())) {
console.error(
`Cannot include files above the working directory (${Deno.cwd()}): ${location} ${html_file}:${index}`
);
break;
}
return await load_html_with_ssi(resolved);
}
default: {
console.error(`Unknown include type: ${type} ${html_file}:${index}`);
break;
}
}
}
);
return processed;
}
/**
* Handles requests for HTML files, processing any server-side includes
*
* @param request The incoming HTTP request
* @returns Either a response (an HTML file was requested and returned properly) or undefined if unhandled.
*/
export default async function handle_html(request: Request): Promise<Response | undefined> {
const url = new URL(request.url);
const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, ''));
if (!normalized_path.startsWith(Deno.cwd())) {
return;
}
const initial_extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? '';
const target_path: string = initial_extension.length === 0 ? path.join(normalized_path, 'index.html') : normalized_path;
const extension = path.extname(target_path).slice(1).toLowerCase();
if (extension !== 'html') {
return;
}
try {
const stat = await Deno.stat(target_path);
if (!stat.isFile) {
return;
}
const processed: string = await load_html_with_ssi(target_path);
const accepts: string = request.headers.get('accept') ?? 'text/html';
const headers: Record<string, string> = {};
switch (accepts) {
case 'text/plain':
headers['Content-Type'] = 'text/plain';
break;
case 'text/html':
default:
headers['Content-Type'] = 'text/html';
break;
}
return new Response(processed, {
headers
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return;
}
if (error instanceof Deno.errors.PermissionDenied) {
return new Response('Permission Denied', {
status: 400,
headers: {
'Content-Type': 'text/plain'
}
});
}
throw error;
}
}