/** * Handles requests for static files. * @module */ import * as fs from '@std/fs'; import * as path from '@std/path'; import * as media_types from '@std/media-types'; import { PRECHECK, SERVER } from '../server.ts'; import { getCookies } from '@std/http/cookie'; function get_allowed_paths(env_var: string) { return (Deno.env.get(env_var) ?? '').split(';').filter((p) => typeof p === 'string' && p.length > 0).map((p) => path.resolve(p)); } export type HTTP_METHOD = 'GET' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS'; export type HANDLER_METHOD = ( request: Request, normalized_path: string, server: SERVER ) => Promise | Response | undefined; export const PRECHECKS: Partial> = {}; export const HANDLERS: Partial> = { HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise => { try { const stat = await Deno.stat(normalized_path); if (stat.isFile) { const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; const content_type = media_types.contentType(extension) ?? 'application/octet-stream'; return new Response('', { headers: { 'Content-Type': content_type, 'Content-Length': `${stat.size}`, 'Last-Modified': `${stat.mtime ?? stat.ctime}` } }); } } catch (error) { if (error instanceof Deno.errors.NotFound) { return; } if (error instanceof Deno.errors.PermissionDenied) { return new Response('Permission Denied', { status: 400 }); } throw error; } }, GET: async (_request: Request, normalized_path: string, _server: SERVER): Promise => { try { const stat = await Deno.stat(normalized_path); if (stat.isFile) { const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; const content_type = media_types.contentType(extension) ?? 'application/octet-stream'; return new Response(await Deno.readFile(normalized_path), { headers: { 'Content-Type': content_type } }); } } catch (error) { if (error instanceof Deno.errors.NotFound) { return; } if (error instanceof Deno.errors.PermissionDenied) { return new Response('Permission Denied', { status: 400 }); } throw error; } }, PUT: async (request: Request, normalized_path: string, server: SERVER): Promise => { const allowed_paths = get_allowed_paths('SERVERUS_PUT_PATHS_ALLOWED'); const allowed = allowed_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path)); if (!allowed) { return new Response('Permission Denied', { status: 400 }); } const root = Deno.cwd(); const body = await request.formData(); const files: File[] = Array.from(body.entries()).map(([_field, value]) => value).filter((value) => value instanceof File); const errors = []; const written = []; if (files.length === 0) { return Response.json({ error: { message: 'You must send at least one file in the form data with a static file PUT request.', cause: 'missing_file' } }, { status: 400 }); } const upload_directory = files.length > 1 ? normalized_path : path.dirname(normalized_path); const all_files_have_names = files.every((file: File) => !!file.name); if (files.length > 1 && !all_files_have_names) { return Response.json({ error: { message: 'You must specify all filenames when uploading to a directory.', cause: 'missing_filename' } }, { status: 400 }); } for await (const file of files) { const filename: string = file.name ?? path.basename(normalized_path); const resolved_upload_name = path.resolve(path.join(upload_directory, filename)); if (!resolved_upload_name.startsWith(root)) { continue; } try { await Deno.mkdir(upload_directory, { recursive: true, mode: 0o755 }); const temp_upload_name = path.join(upload_directory, `.${filename}.${new Date().toISOString()}`); await Deno.writeFile(temp_upload_name, file.stream(), { mode: 0o755, create: true, createNew: true, signal: request.signal }); await file.stream().cancel(); const resolved_path_exists = await fs.exists(resolved_upload_name); if (resolved_path_exists) { await Deno.remove(resolved_upload_name); } await Deno.rename(temp_upload_name, resolved_upload_name); const base_url = request.url.toString(); const ends_with_filename = base_url.endsWith(filename); const url = base_url + (ends_with_filename ? '' : filename); written.push(url); server.emit('static.put', { url, normalized_path }); } catch (error) { errors.push(error); } } if (errors.length) { return Response.json({ errors }, { status: 400 }); } return Response.json({ written }, { status: 201 }); }, DELETE: async (request: Request, normalized_path: string, server: SERVER): Promise => { const allowed_paths = get_allowed_paths('SERVERUS_DELETE_PATHS_ALLOWED'); const allowed = allowed_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path)); if (!allowed) { return new Response('Permission Denied', { status: 400 }); } try { const stat = await Deno.lstat(normalized_path); if (stat.isDirectory) { await Deno.remove(normalized_path, { recursive: true }); } else if (stat.isFile || stat.isSymlink) { await Deno.remove(normalized_path); const directory = path.dirname(normalized_path); const is_empty = Array.from(Deno.readDirSync(directory)).length === 0; if (is_empty) { await Deno.remove(directory, { recursive: true }); } } else { return new Response('Permission Denied', { status: 400 }); } server.emit('static.delete', { url: request.url.toString(), normalized_path }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: 'Not Found' }, { status: 404 }); } return Response.json({ error }, { status: 500 }); } return Response.json({}, { status: 200 }); }, OPTIONS: (_request: Request, normalized_path: string): Response | undefined => { const allowed = ['GET', 'HEAD', 'OPTIONS']; const allowed_put_paths = get_allowed_paths('SERVERUS_PUT_PATHS_ALLOWED'); if (allowed_put_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path))) { allowed.push('PUT'); } const allowed_delete_paths = get_allowed_paths('SERVERUS_DELETE_PATHS_ALLOWED'); if (allowed_delete_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path))) { allowed.push('DELETE'); } return new Response('', { headers: { 'Allow': allowed.sort().join(','), 'Access-Control-Allow-Origin': Deno.env.get('SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN') ?? '*' } }); } }; /** * Handles requests for static files. * * @param request The incoming HTTP request * @returns Either a response (a static file was requested and returned properly) or undefined if unhandled. */ export default async function handle_static_files(request: Request, server: SERVER): Promise { const method: HTTP_METHOD = request.method.toUpperCase() as HTTP_METHOD; const handler: HANDLER_METHOD | undefined = HANDLERS[method]; if (!handler) { return; } const url = new URL(request.url); const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, '')); // if they're requesting something outside the working dir, just bail if (!normalized_path.startsWith(Deno.cwd())) { return; } const prechecks: PRECHECK[] = PRECHECKS[method] ?? []; const cookies: Record = getCookies(request.headers); const query = Object.fromEntries(new URL(request.url).searchParams.entries()); const metadata = { cookies, query }; for await (const precheck of prechecks) { const error_response: Response | undefined = await precheck(request, metadata); if (error_response) { return error_response; } } return await handler(request, normalized_path, server); }