feature: allow for static file uploads and deletions

feature: add HEAD and OPTIONS support to static files
This commit is contained in:
Andy Burke 2025-08-01 20:11:17 -07:00
parent 582636ab5a
commit 3ef936d2d6
7 changed files with 900 additions and 120 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
data/
tests/data
serverus
tests/www/files

View file

@ -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

View file

@ -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",

View file

@ -3,27 +3,55 @@
* @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';
/**
* 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): Promise<Response | undefined> {
// we only handle GET on static files
if (request.method.toUpperCase() !== 'GET') {
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;
}
const url = new URL(request.url);
const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, ''));
if (!normalized_path.startsWith(Deno.cwd())) {
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);
@ -44,13 +72,237 @@ export default async function handle_static_files(request: Request): Promise<Res
if (error instanceof Deno.errors.PermissionDenied) {
return new Response('Permission Denied', {
status: 400,
headers: {
'Content-Type': 'text/plain'
}
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);
}

105
server.ts
View file

@ -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> | 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> | 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<void>;
/** 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<void>;
}
/**
* 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<string, []>;
/**
* @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<void> {
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);
}
}
}

View file

@ -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();
}
}
}
});

View file

@ -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();
}
}
}
});