2025-06-19 15:55:35 -07:00
|
|
|
/**
|
|
|
|
* Handles requests for static files.
|
|
|
|
* @module
|
|
|
|
*/
|
|
|
|
|
2025-08-01 20:11:17 -07:00
|
|
|
import * as fs from '@std/fs';
|
2025-06-19 15:43:01 -07:00
|
|
|
import * as path from '@std/path';
|
|
|
|
import * as media_types from '@std/media-types';
|
2025-08-08 17:38:15 -07:00
|
|
|
import { PRECHECK, SERVER } from '../server.ts';
|
2025-08-08 17:28:29 -07:00
|
|
|
import { getCookies } from '@std/http/cookie';
|
2025-08-01 20:11:17 -07:00
|
|
|
|
2025-08-12 12:25:12 -07:00
|
|
|
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));
|
|
|
|
}
|
2025-08-01 20:11:17 -07:00
|
|
|
|
|
|
|
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 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> => {
|
2025-08-12 12:25:12 -07:00
|
|
|
const allowed_paths = get_allowed_paths('SERVERUS_PUT_PATHS_ALLOWED');
|
|
|
|
const allowed = allowed_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path));
|
2025-08-01 20:11:17 -07:00
|
|
|
|
|
|
|
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) {
|
2025-08-12 15:24:55 -07:00
|
|
|
const filename: string = file.name ? decodeURIComponent(file.name) : path.basename(normalized_path);
|
2025-08-01 20:11:17 -07:00
|
|
|
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> => {
|
2025-08-12 12:25:12 -07:00
|
|
|
const allowed_paths = get_allowed_paths('SERVERUS_DELETE_PATHS_ALLOWED');
|
|
|
|
const allowed = allowed_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path));
|
2025-08-01 20:11:17 -07:00
|
|
|
|
|
|
|
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'];
|
|
|
|
|
2025-08-12 12:25:12 -07:00
|
|
|
const allowed_put_paths = get_allowed_paths('SERVERUS_PUT_PATHS_ALLOWED');
|
|
|
|
if (allowed_put_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path))) {
|
2025-08-01 20:11:17 -07:00
|
|
|
allowed.push('PUT');
|
|
|
|
}
|
|
|
|
|
2025-08-12 12:25:12 -07:00
|
|
|
const allowed_delete_paths = get_allowed_paths('SERVERUS_DELETE_PATHS_ALLOWED');
|
|
|
|
if (allowed_delete_paths.some((allowed_path: string) => normalized_path.startsWith(allowed_path))) {
|
2025-08-01 20:11:17 -07:00
|
|
|
allowed.push('DELETE');
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Response('', {
|
|
|
|
headers: {
|
|
|
|
'Allow': allowed.sort().join(','),
|
|
|
|
'Access-Control-Allow-Origin': Deno.env.get('SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN') ?? '*'
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
2025-06-19 15:43:01 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2025-08-01 20:11:17 -07:00
|
|
|
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) {
|
2025-07-31 14:53:53 -07:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-06-19 15:43:01 -07:00
|
|
|
const url = new URL(request.url);
|
2025-08-12 15:33:32 -07:00
|
|
|
const normalized_path = path.resolve(path.normalize(decodeURIComponent(url.pathname)).replace(/^\/+/, ''));
|
2025-08-01 20:11:17 -07:00
|
|
|
|
|
|
|
// if they're requesting something outside the working dir, just bail
|
2025-06-19 15:43:01 -07:00
|
|
|
if (!normalized_path.startsWith(Deno.cwd())) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-08-01 20:11:17 -07:00
|
|
|
const prechecks: PRECHECK[] = PRECHECKS[method] ?? [];
|
2025-06-19 15:43:01 -07:00
|
|
|
|
2025-08-08 17:28:29 -07:00
|
|
|
const cookies: Record<string, string> = getCookies(request.headers);
|
|
|
|
const query = Object.fromEntries(new URL(request.url).searchParams.entries());
|
|
|
|
|
2025-08-11 17:47:51 -07:00
|
|
|
const metadata = {
|
|
|
|
cookies,
|
|
|
|
query
|
|
|
|
};
|
|
|
|
|
2025-08-01 20:11:17 -07:00
|
|
|
for await (const precheck of prechecks) {
|
2025-08-11 17:44:56 -07:00
|
|
|
const error_response: Response | undefined = await precheck(request, metadata);
|
2025-08-01 20:11:17 -07:00
|
|
|
if (error_response) {
|
|
|
|
return error_response;
|
2025-06-19 15:43:01 -07:00
|
|
|
}
|
|
|
|
}
|
2025-08-01 20:11:17 -07:00
|
|
|
|
|
|
|
return await handler(request, normalized_path, server);
|
2025-06-19 15:43:01 -07:00
|
|
|
}
|