serverus/handlers/static.ts

309 lines
8.2 KiB
TypeScript
Raw Normal View History

2025-06-19 15:55:35 -07:00
/**
* 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 { SERVER } from '@andyburke/serverus/server';
let PUT_PATHS_ALLOWED: string[] | undefined = undefined;
let DELETE_PATHS_ALLOWED: string[] | undefined = undefined;
export type HTTP_METHOD = 'GET' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS';
export type HANDLER_METHOD = (
request: Request,
normalized_path: string,
server: SERVER
) => Promise<Response | undefined> | Response | undefined;
export type PRECHECK = (request: Request) => Promise<Response> | Response | undefined;
export const PRECHECKS: Partial<Record<HTTP_METHOD, PRECHECK[]>> = {};
export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise<Response | undefined> => {
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<Response | undefined> => {
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<Response | undefined> => {
PUT_PATHS_ALLOWED = PUT_PATHS_ALLOWED ??
(Deno.env.get('SERVERUS_PUT_PATHS_ALLOWED') ?? '').split(';');
const allowed = PUT_PATHS_ALLOWED.some((allowed_put_path: string) => normalized_path.startsWith(allowed_put_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<Response | undefined> => {
DELETE_PATHS_ALLOWED = DELETE_PATHS_ALLOWED ??
(Deno.env.get('SERVERUS_DELETE_PATHS_ALLOWED') ?? '').split(';');
const allowed = DELETE_PATHS_ALLOWED.some((allowed_delete_path: string) => normalized_path.startsWith(allowed_delete_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'];
PUT_PATHS_ALLOWED = PUT_PATHS_ALLOWED ??
(Deno.env.get('SERVERUS_PUT_PATHS_ALLOWED') ?? '').split(';').map((put_path) => path.resolve(path.join(Deno.cwd(), put_path)));
if (PUT_PATHS_ALLOWED.some((allowed_put_path: string) => normalized_path.startsWith(allowed_put_path))) {
allowed.push('PUT');
}
DELETE_PATHS_ALLOWED = DELETE_PATHS_ALLOWED ??
(Deno.env.get('SERVERUS_DELETE_PATHS_ALLOWED') ?? '').split(';').map((delete_path) =>
path.resolve(path.join(Deno.cwd(), delete_path))
);
if (DELETE_PATHS_ALLOWED.some((allowed_delete_path: string) => normalized_path.startsWith(allowed_delete_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<Response | undefined> {
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] ?? [];
for await (const precheck of prechecks) {
const error_response: Response | undefined = await precheck(request);
if (error_response) {
return error_response;
}
}
return await handler(request, normalized_path, server);
}