From 3ef936d2d676e1153edd36de5b5ed2dda4555ca8 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Fri, 1 Aug 2025 20:11:17 -0700 Subject: [PATCH] feature: allow for static file uploads and deletions feature: add HEAD and OPTIONS support to static files --- .gitignore | 1 + README.md | 2 + deno.json | 2 +- handlers/static.ts | 310 ++++++++++++++++-- server.ts | 105 ++++++- tests/01_get_static_file.test.ts | 79 ----- tests/01_static_files.test.ts | 521 +++++++++++++++++++++++++++++++ 7 files changed, 900 insertions(+), 120 deletions(-) delete mode 100644 tests/01_get_static_file.test.ts create mode 100644 tests/01_static_files.test.ts diff --git a/.gitignore b/.gitignore index 89b28f3..5d39b42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data/ tests/data serverus +tests/www/files diff --git a/README.md b/README.md index ae10020..a2118d3 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ listening. - `SERVERUS_ROOT`: set the root, aka --root on the command line - `SERVERUS_HANDLERS`: a list of ;-separated directories to look for handlers in + - `SERVERUS_PUT_PATHS_ALLOWED`: a list of ;-separated directories for which file uploads via PUT are allowed + - `SERVERUS_DELETE_PATHS_ALLOWED`: a list of ;-separated directories for which file deletions via DELETE are allowed ### Typescript Handling diff --git a/deno.json b/deno.json index d2e6afb..92b3242 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "name": "@andyburke/serverus", "description": "A flexible HTTP server for mixed content. Throw static files, markdown, Typescript and (hopefully, eventually) more into a directory and serverus can serve it up a bit more like old-school CGI.", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "exports": { ".": "./serverus.ts", diff --git a/handlers/static.ts b/handlers/static.ts index d63a5cd..c5fb74a 100644 --- a/handlers/static.ts +++ b/handlers/static.ts @@ -3,8 +3,275 @@ * @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; +export type PRECHECK = (request: Request) => 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 => { + 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 => { + 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. @@ -12,45 +279,30 @@ import * as media_types from '@std/media-types'; * @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): Promise { - // we only handle GET on static files - if (request.method.toUpperCase() !== 'GET') { +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; } - try { - const stat = await Deno.stat(normalized_path); + const prechecks: PRECHECK[] = PRECHECKS[method] ?? []; - 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 - } - }); + for await (const precheck of prechecks) { + const error_response: Response | undefined = await precheck(request); + if (error_response) { + return error_response; } - } 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; } + + return await handler(request, normalized_path, server); } diff --git a/server.ts b/server.ts index ea25b86..353c4be 100644 --- a/server.ts +++ b/server.ts @@ -10,20 +10,26 @@ const EVENTS_TO_SHUTDOWN_ON: Deno.Signal[] = ['SIGTERM', 'SIGINT']; const DEFAULT_HANDLER_DIRECTORIES = [import.meta.resolve('./handlers')]; -/** A `HANDLER` must take a `Request` and return a `Response` if it can handle it. */ -type HANDLER = (request: Request) => Promise | Response | null | undefined; +/** + * @type HANDLER Takes a `Request` and returns a `Response` if it can properly handle the request. + */ +type HANDLER = (request: Request, server: SERVER) => Promise | Response | null | undefined; -/** A `LOGGER` must take a `Request`, a `Response`, and a `processing_time` and log it. */ +/** + * @type LOGGER Takes a `Request`, `Response`, and a `processing_time` in ms to be logged. + */ type LOGGER = (request: Request, response: Response, processing_time: number) => void | Promise; -/** A `HANDLER_MODULE` must export a default method and may export an unload method to be called at shutdown. */ +/** + * @interface HANDLER_MODULE Handler modules should export a default method (handler) and an optional `unload` method to be called at shutdown. + */ interface HANDLER_MODULE { default: HANDLER; unload?: () => void | Promise; } /** - * Interface defining the configuration for a serverus server + * @type SERVER_OPTIONS Specifies options for creating the SERVERUS server. * * @property {string} [hostname='localhost'] - hostname to bind to * @property {number} [port=8000] - port to bind to @@ -51,7 +57,7 @@ export const DEFAULT_SERVER_OPTIONS: SERVER_OPTIONS = { }; /** - * Default logger + * @method LOG_REQUEST Default request logger. * * @param {Request} request - the incoming request * @param {Response} response - the outgoing response @@ -71,17 +77,20 @@ function LOG_REQUEST(request: Request, response: Response, time: number) { } /** - * serverus SERVER - * - * Loads all handlers found in the [semi-]colon separated list of directories in + * @class SERVER Loads all handlers found in the [semi-]colon separated list of directories in `SERVERUS_ROOT`. */ export class SERVER { private options: SERVER_OPTIONS; private server: Deno.HttpServer | undefined; private controller: AbortController | undefined; private shutdown_binding: (() => void) | undefined; - private handlers: HANDLER_MODULE[]; private original_directory: string | undefined; + private event_listeners: Record; + + /** + * @member handlers The HANDLER_MODULEs loaded for this server. + */ + public handlers: HANDLER_MODULE[]; /** * @param {SERVER_OPTIONS} (optional) options to configure the server @@ -92,6 +101,7 @@ export class SERVER { ...(options ?? {}) }; this.handlers = []; + this.event_listeners = {}; } /** @@ -163,7 +173,7 @@ export class SERVER { : (this.options.logging ? LOG_REQUEST : undefined); for (const handler_module of this.handlers) { - const response = await handler_module.default(request); + const response = await handler_module.default(request, this); if (response) { logger?.(request, response, Date.now() - request_time); return response; @@ -189,6 +199,8 @@ export class SERVER { // Deno.watchFs; // } + this.emit('started', {}); + return this; } @@ -196,6 +208,8 @@ export class SERVER { * Stop the server */ public async stop(): Promise { + this.emit('stopping', {}); + if (this.server) { this.server.finished.finally(() => { if (this.shutdown_binding) { @@ -226,5 +240,74 @@ export class SERVER { } } this.handlers = []; + + this.emit('stopped', {}); + } + + /** + * Add an event listener. + * + * @param {string} event The event to listen for. + * @param {(event_data: any) => void} handler The handler for the event. + */ + public on(event: string, handler: (event_data: any) => void) { + const listeners: ((event: any) => void)[] = this.event_listeners[event] = this.event_listeners[event] ?? []; + if (!listeners.includes(handler)) { + listeners.push(handler); + } + + if (Deno.env.get('SERVERUS_LOG_EVENTS')) { + console.dir({ + on: { + event, + handler + }, + listeners + }); + } + } + + /** + * Remove an event listener. + * + * @param {string} event The event that was listened to. + * @param {(event_data: any) => void} handler The handler that was registered that should be removed. + */ + public off(event: string, handler: (event_data: any) => void) { + const listeners: ((event: any) => void)[] = this.event_listeners[event] = this.event_listeners[event] ?? []; + if (listeners.includes(handler)) { + listeners.splice(listeners.indexOf(handler), 1); + } + + if (Deno.env.get('SERVERUS_LOG_EVENTS')) { + console.dir({ + off: { + event: event, + handler + }, + listeners + }); + } + } + + public emit(event_name: string, event_data: any) { + const listeners: ((event: any) => void)[] = this.event_listeners[event_name] = this.event_listeners[event_name] ?? []; + const wildcard_listeners: ((event: any) => void)[] = this.event_listeners['*'] = this.event_listeners['*'] ?? []; + const all_listeners: ((event: any) => void)[] = [...listeners, ...wildcard_listeners]; + + if (Deno.env.get('SERVERUS_LOG_EVENTS')) { + console.dir({ + emitting: { + event_name, + event_data, + listeners, + wildcard_listeners + } + }); + } + + for (const listener of all_listeners) { + listener(event_data); + } } } diff --git a/tests/01_get_static_file.test.ts b/tests/01_get_static_file.test.ts deleted file mode 100644 index c7276b5..0000000 --- a/tests/01_get_static_file.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as asserts from '@std/assert'; -import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; - -Deno.test({ - name: 'get static file', - permissions: { - env: true, - read: true, - write: true, - net: true - }, - fn: async () => { - let test_server_info: EPHEMERAL_SERVER | null = null; - const cwd = Deno.cwd(); - - try { - Deno.chdir('./tests/www'); - test_server_info = await get_ephemeral_listen_server(); - - const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { - method: 'GET' - }); - - const body = await response.text(); - - asserts.assert(response.ok); - asserts.assert(body); - asserts.assertEquals(body, 'this is a test\n'); - } finally { - Deno.chdir(cwd); - if (test_server_info) { - await test_server_info?.server?.stop(); - } - } - } -}); - -Deno.test({ - name: 'other methods than GET should not work on static files', - permissions: { - env: true, - read: true, - write: true, - net: true - }, - fn: async () => { - let test_server_info: EPHEMERAL_SERVER | null = null; - const cwd = Deno.cwd(); - - try { - Deno.chdir('./tests/www'); - test_server_info = await get_ephemeral_listen_server(); - - for await (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { - const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { - method, - body: method === 'DELETE' ? undefined : JSON.stringify({}) - }); - - asserts.assert(!response.ok); - - const body = await response.json(); - asserts.assert(body); - - asserts.assertEquals(body, { - error: { - cause: 'not_found', - message: 'Not found' - } - }); - } - } finally { - Deno.chdir(cwd); - if (test_server_info) { - await test_server_info?.server?.stop(); - } - } - } -}); diff --git a/tests/01_static_files.test.ts b/tests/01_static_files.test.ts new file mode 100644 index 0000000..5f501e7 --- /dev/null +++ b/tests/01_static_files.test.ts @@ -0,0 +1,521 @@ +import * as asserts from '@std/assert'; +import * as fs from '@std/fs'; +import * as path from '@std/path'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; +import { ensureFile } from '@std/fs/ensure-file'; + +Deno.test({ + name: 'GET static file', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertEquals(body, 'this is a test\n'); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'HEAD static file', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { + method: 'HEAD' + }); + + asserts.assert(response.ok); + asserts.assert(response.headers); + asserts.assertEquals(response.headers.get('Content-Length'), '15'); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'OPTIONS static file', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { + method: 'OPTIONS' + }); + + await response.text(); + + asserts.assert(response.ok); + asserts.assert(response.headers); + asserts.assertEquals(response.headers.get('Allow'), ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT'].join(',')); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'allow PUT to static files if SERVERUS_PUT_PATHS_ALLOWED is set', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + + sanitizeResources: false, + sanitizeOps: false, + + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + const PREVIOUS_PUT_PATHS_ALLOWED = Deno.env.get('SERVERUS_PUT_PATHS_ALLOWED'); + try { + Deno.chdir('./tests/www'); + Deno.env.set('SERVERUS_PUT_PATHS_ALLOWED', path.join(Deno.cwd(), 'files')); + + test_server_info = await get_ephemeral_listen_server(); + + const put_body = new FormData(); + let test_file: File | undefined = new File(['this is a test PUT upload'], 'test_put_upload.txt'); + put_body.append('file', test_file); + + // Sending a single file + const put_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/files/test_put_upload.txt`, { + method: 'PUT', + body: put_body + }); + + asserts.assert(put_response.ok); + + const put_response_body = await put_response.json(); + asserts.assert(put_response_body); + asserts.assert(put_response_body.written); + asserts.assertEquals( + put_response_body.written?.[0], + `http://${test_server_info.hostname}:${test_server_info.port}/files/test_put_upload.txt` + ); + + put_body.delete('file'); + test_file = undefined; + + const get_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/files/test_put_upload.txt`, { + method: 'GET' + }); + + asserts.assert(get_response.ok); + asserts.assert(get_response.body); + + const local_download_path = path.join(Deno.cwd(), 'files', 'test_put_upload.txt-downloaded'); + await ensureFile(local_download_path); + + const file = await Deno.open(local_download_path, { truncate: true, write: true }); + await get_response.body.pipeTo(file.writable); + + const download_content = await Deno.readTextFile(local_download_path); + asserts.assert(download_content); + asserts.assertEquals(download_content, 'this is a test PUT upload'); + + await Deno.remove(local_download_path); + asserts.assert(!fs.existsSync(local_download_path)); + + const local_upload_path = path.join(Deno.cwd(), 'files', 'test_put_upload.txt'); + asserts.assert(fs.existsSync(local_upload_path)); + + const stat = await Deno.lstat(local_upload_path); + asserts.assert(stat); + asserts.assert(stat.isFile); + asserts.assertEquals(stat.size, 25); + asserts.assertEquals(stat.mode! & 0o777, 0o755); + + await Deno.remove(local_upload_path); + asserts.assert(!fs.existsSync(local_upload_path)); + } finally { + Deno.chdir(cwd); + if (PREVIOUS_PUT_PATHS_ALLOWED) { + Deno.env.set('SERVERUS_PUT_PATHS_ALLOWED', PREVIOUS_PUT_PATHS_ALLOWED); + } else { + Deno.env.delete('SERVERUS_PUT_PATHS_ALLOWED'); + } + + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'allow PUT to multiple files in a directory', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + + sanitizeResources: false, + sanitizeOps: false, + + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + const PREVIOUS_PUT_PATHS_ALLOWED = Deno.env.get('SERVERUS_PUT_PATHS_ALLOWED'); + try { + Deno.chdir('./tests/www'); + Deno.env.set('SERVERUS_PUT_PATHS_ALLOWED', path.join(Deno.cwd(), 'files')); + + test_server_info = await get_ephemeral_listen_server(); + + const put_body = new FormData(); + + for (const i of [1, 2, 3]) { + put_body.append('file', new File([`this is a test PUT upload ${i}`], `test_put_upload_${i}.txt`)); + } + + const put_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/files/test_multiple/`, { + method: 'PUT', + body: put_body + }); + + asserts.assert(put_response.ok); + + const put_response_body = await put_response.json(); + asserts.assert(put_response_body); + asserts.assert(put_response_body.written); + + for await (const i of [1, 2, 3]) { + const url = put_response_body.written?.[i - 1]; + + asserts.assertEquals( + url, + `http://${test_server_info.hostname}:${test_server_info.port}/files/test_multiple/test_put_upload_${i}.txt` + ); + + const get_response = await fetch(url, { + method: 'GET' + }); + + asserts.assert(get_response.ok); + asserts.assert(get_response.body); + + const local_download_path = path.join(Deno.cwd(), 'files', 'test_multiple', `test_put_upload_${i}.txt-downloaded`); + await ensureFile(local_download_path); + + const file = await Deno.open(local_download_path, { truncate: true, write: true }); + await get_response.body.pipeTo(file.writable); + + const download_content = await Deno.readTextFile(local_download_path); + asserts.assert(download_content); + asserts.assertEquals(download_content, `this is a test PUT upload ${i}`); + + await Deno.remove(local_download_path); + asserts.assert(!fs.existsSync(local_download_path)); + + const local_upload_path = path.join(Deno.cwd(), 'files', 'test_multiple', `test_put_upload_${i}.txt`); + asserts.assert(fs.existsSync(local_upload_path)); + + const stat = await Deno.lstat(local_upload_path); + asserts.assert(stat); + asserts.assert(stat.isFile); + asserts.assertEquals(stat.size, 27); + asserts.assertEquals(stat.mode! & 0o777, 0o755); + + await Deno.remove(local_upload_path); + asserts.assert(!fs.existsSync(local_upload_path)); + } + + const uploads_directory = path.join(Deno.cwd(), 'files', 'test_multiple'); + await Deno.remove(uploads_directory); + asserts.assert(!fs.existsSync(uploads_directory)); + } finally { + Deno.chdir(cwd); + if (PREVIOUS_PUT_PATHS_ALLOWED) { + Deno.env.set('SERVERUS_PUT_PATHS_ALLOWED', PREVIOUS_PUT_PATHS_ALLOWED); + } else { + Deno.env.delete('SERVERUS_PUT_PATHS_ALLOWED'); + } + + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'allow DELETE to static files if SERVERUS_DELETE_PATHS_ALLOWED is set', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + + sanitizeResources: false, + sanitizeOps: false, + + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + const PREVIOUS_DELETE_PATHS_ALLOWED = Deno.env.get('SERVERUS_DELETE_PATHS_ALLOWED'); + try { + Deno.chdir('./tests/www'); + Deno.env.set('SERVERUS_DELETE_PATHS_ALLOWED', path.join(Deno.cwd(), 'files')); + + test_server_info = await get_ephemeral_listen_server(); + + const put_body = new FormData(); + let test_file: File | undefined = new File(['this is a test DELETE upload'], 'test_delete_upload.txt'); + put_body.append('file', test_file); + + const put_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/files/test_delete_upload.txt`, { + method: 'PUT', + body: put_body + }); + + asserts.assert(put_response.ok); + + const put_response_body = await put_response.json(); + asserts.assert(put_response_body); + asserts.assert(put_response_body.written); + asserts.assertEquals( + put_response_body.written?.[0], + `http://${test_server_info.hostname}:${test_server_info.port}/files/test_delete_upload.txt` + ); + + put_body.delete('file'); + test_file = undefined; + + const get_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/files/test_delete_upload.txt`, { + method: 'GET' + }); + + asserts.assert(get_response.ok); + asserts.assert(get_response.body); + + const local_download_path = path.join(Deno.cwd(), 'files', 'test_delete_upload.txt-downloaded'); + await ensureFile(local_download_path); + + const file = await Deno.open(local_download_path, { truncate: true, write: true }); + await get_response.body.pipeTo(file.writable); + + const download_content = await Deno.readTextFile(local_download_path); + asserts.assert(download_content); + asserts.assertEquals(download_content, 'this is a test DELETE upload'); + + await Deno.remove(local_download_path); + asserts.assert(!fs.existsSync(local_download_path)); + + const local_upload_path = path.join(Deno.cwd(), 'files', 'test_delete_upload.txt'); + asserts.assert(fs.existsSync(local_upload_path)); + + const stat = await Deno.lstat(local_upload_path); + asserts.assert(stat); + asserts.assert(stat.isFile); + asserts.assertEquals(stat.size, 28); + asserts.assertEquals(stat.mode! & 0o777, 0o755); + + const delete_response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/files/test_delete_upload.txt`, + { + method: 'DELETE' + } + ); + + asserts.assert(delete_response.ok); + + asserts.assert(!fs.existsSync(local_upload_path)); + } finally { + Deno.chdir(cwd); + if (PREVIOUS_DELETE_PATHS_ALLOWED) { + Deno.env.set('SERVERUS_DELETE_PATHS_ALLOWED', PREVIOUS_DELETE_PATHS_ALLOWED); + } else { + Deno.env.delete('SERVERUS_DELETE_PATHS_ALLOWED'); + } + + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'allow DELETE directory', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + + sanitizeResources: false, + sanitizeOps: false, + + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + const PREVIOUS_DELETE_PATHS_ALLOWED = Deno.env.get('SERVERUS_DELETE_PATHS_ALLOWED'); + try { + Deno.chdir('./tests/www'); + Deno.env.set('SERVERUS_DELETE_PATHS_ALLOWED', path.join(Deno.cwd(), 'files')); + + test_server_info = await get_ephemeral_listen_server(); + + const put_body = new FormData(); + let test_file: File | undefined = new File(['this is a test DELETE upload'], 'test_delete_directory_upload.txt'); + put_body.append('file', test_file); + + const put_response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/files/delete_directory_test/test_delete_directory_upload.txt`, + { + method: 'PUT', + body: put_body + } + ); + + asserts.assert(put_response.ok); + + const put_response_body = await put_response.json(); + asserts.assert(put_response_body); + asserts.assert(put_response_body.written); + asserts.assertEquals( + put_response_body.written?.[0], + `http://${test_server_info.hostname}:${test_server_info.port}/files/delete_directory_test/test_delete_directory_upload.txt` + ); + + put_body.delete('file'); + test_file = undefined; + + const local_upload_path = path.join(Deno.cwd(), 'files', 'delete_directory_test', 'test_delete_directory_upload.txt'); + asserts.assert(fs.existsSync(local_upload_path)); + + const stat = await Deno.lstat(local_upload_path); + asserts.assert(stat); + asserts.assert(stat.isFile); + asserts.assertEquals(stat.size, 28); + asserts.assertEquals(stat.mode! & 0o777, 0o755); + + const delete_response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/files/delete_directory_test`, + { + method: 'DELETE' + } + ); + + asserts.assert(delete_response.ok); + + asserts.assert(!fs.existsSync(local_upload_path)); + asserts.assert(!fs.existsSync(path.dirname(local_upload_path))); + } finally { + Deno.chdir(cwd); + if (PREVIOUS_DELETE_PATHS_ALLOWED) { + Deno.env.set('SERVERUS_DELETE_PATHS_ALLOWED', PREVIOUS_DELETE_PATHS_ALLOWED); + } else { + Deno.env.delete('SERVERUS_DELETE_PATHS_ALLOWED'); + } + + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'these methods should not work on static files', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + for await (const method of ['POST', 'PATCH']) { + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { + method, + body: method === 'TRACE' ? undefined : JSON.stringify({}) + }); + + asserts.assert(!response.ok); + + const body = await response.json(); + asserts.assert(body); + + asserts.assertEquals(body, { + error: { + cause: 'not_found', + message: 'Not found' + } + }); + } + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +});