nephele
Version:
Highly customizable and extensible WebDAV server for Node.js and Express.
496 lines (452 loc) • 13.7 kB
text/typescript
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import express, { NextFunction, Request } from 'express';
import cookieParser from 'cookie-parser';
import createDebug from 'debug';
import { nanoid } from 'nanoid';
import type { AuthResponse } from './Interfaces/index.js';
import type { Config, Options } from './Options.js';
import {
defaults,
getAdapter,
getAuthenticator,
getPlugins,
} from './Options.js';
import { catchErrors } from './catchErrors.js';
import { ForbiddenError, UnauthorizedError } from './Errors/index.js';
import {
COPY,
DELETE,
GET_HEAD,
LOCK,
MKCOL,
MOVE,
OPTIONS,
PROPFIND,
PROPPATCH,
PUT,
UNLOCK,
Method,
} from './Methods/index.js';
const debug = createDebug('nephele:server');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json')).toString(),
);
/**
* A server middleware creator for Nephele, a WebDAV server library.
*
* In order to use this server, you must provide it with an adapter. Your
* Nephele adapter is responsible for actually making changes to the
* database/filsystem/whatever you use to manage resources.
*
* Written by Hunter Perrin for SciActive.
*
* @author Hunter Perrin <hperrin@gmail.com>
* @copyright SciActive Inc
* @see http://sciactive.com/
*/
export default function createServer(
{ adapter, authenticator, plugins }: Config,
options: Partial<Options> = {},
) {
const opts = Object.assign({}, defaults, options) as Options;
const app = express();
app.disable('etag');
app.use(cookieParser());
async function debugLogger(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
response.locals.requestId = nanoid(5);
response.locals.debug = debug.extend(`${response.locals.requestId}`);
response.locals.debug(
`IP: ${request.ip}, Method: ${request.method}, URL: ${request.originalUrl}`,
);
response.locals.errors = [];
next();
}
// Log the details of the request.
app.use(debugLogger);
async function debugLoggerEnd(
_request: Request,
response: AuthResponse,
next: NextFunction,
) {
response.on('close', () => {
response.locals.debug(
`Response: ${response.statusCode} ${response.statusMessage || ''}`,
);
for (let error of response.locals.errors) {
response.locals.debug(
'Error Message: %s',
'message' in error ? error.message : error,
);
}
});
next();
}
// Log the details of the response.
app.use(debugLoggerEnd);
async function loadPlugins(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
if (plugins == null) {
response.locals.pluginsConfig = plugins;
response.locals.plugins = [];
} else {
const pluginArray =
typeof plugins === 'function'
? await plugins(request, response)
: plugins;
response.locals.pluginsConfig = pluginArray;
const parsedPlugins = getPlugins(
decodeURIComponent(request.path).replace(/\/?$/, () => '/'),
pluginArray,
);
const baseUrl = new URL(
path.join(request.baseUrl || '/', parsedPlugins.baseUrl),
`${request.protocol}://${request.headers.host}`,
);
response.locals.plugins = parsedPlugins.plugins;
response.locals.plugins.forEach((plugin) => (plugin.baseUrl = baseUrl));
}
next();
}
// Get the plugins.
app.use(loadPlugins);
// Run plugin prepare.
app.use(
async (request: Request, response: AuthResponse, next: NextFunction) => {
let ended = false;
await catchErrors(
async () => {
for (let plugin of response.locals.plugins) {
if ('prepare' in plugin && plugin.prepare) {
const result = await plugin.prepare(request, response);
if (result === false) {
ended = true;
}
}
}
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
ended = true;
},
)();
if (!ended) {
next();
}
},
);
async function loadAuthenticator(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
const auth =
typeof authenticator === 'function'
? await authenticator(request, response)
: authenticator;
response.locals.authenticatorConfig = auth;
response.locals.authenticator = getAuthenticator(
decodeURIComponent(request.path).replace(/\/?$/, () => '/'),
auth,
);
next();
}
// Get the authenticator.
app.use(loadAuthenticator);
async function loadAdapter(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
const adapt =
typeof adapter === 'function'
? await adapter(request, response)
: adapter;
response.locals.adapterConfig = adapt;
const parsedAdapter = await getAdapter(
decodeURIComponent(request.path).replace(/\/?$/, () => '/'),
adapt,
{
request,
response,
plugins: response.locals.plugins,
},
);
response.locals.adapter = parsedAdapter.adapter;
response.locals.baseUrl = new URL(
path.join(request.baseUrl || '/', parsedAdapter.baseUrl),
`${request.protocol}://${request.headers.host}`,
);
next();
}
// Get the initial adapter (before authentication).
app.use(loadAdapter);
// Run plugin beforeAuth.
app.use(
async (request: Request, response: AuthResponse, next: NextFunction) => {
let ended = false;
await catchErrors(
async () => {
for (let plugin of response.locals.plugins) {
if ('beforeAuth' in plugin && plugin.beforeAuth) {
const result = await plugin.beforeAuth(request, response);
if (result === false) {
ended = true;
}
}
}
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
ended = true;
},
)();
if (!ended) {
next();
}
},
);
async function authenticate(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
try {
if (request.method === 'OPTIONS') {
response.locals.debug(`Skipping authentication for OPTIONS request.`);
} else {
response.locals.debug(`Authenticating user.`);
response.locals.user = await response.locals.authenticator.authenticate(
request,
response,
);
}
} catch (e: any) {
response.locals.debug(`Auth failed.`);
response.locals.errors.push(e);
if (e instanceof UnauthorizedError) {
response.status(401);
opts.errorHandler(401, 'Unauthorized.', request, response, e);
return;
}
if (e instanceof ForbiddenError) {
response.status(403);
opts.errorHandler(403, 'Forbidden.', request, response, e);
return;
}
response.locals.debug('Error: %o', e);
response.status(500);
opts.errorHandler(500, 'Internal server error.', request, response, e);
return;
}
next();
}
// Authenticate before the request.
app.use(authenticate);
// Run plugin afterAuth.
app.use(
async (request: Request, response: AuthResponse, next: NextFunction) => {
let ended = false;
await catchErrors(
async () => {
for (let plugin of response.locals.plugins) {
if ('afterAuth' in plugin && plugin.afterAuth) {
const result = await plugin.afterAuth(request, response);
if (result === false) {
ended = true;
}
}
}
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
ended = true;
},
)();
if (!ended) {
next();
}
},
);
async function unauthenticate(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
response.on('close', async () => {
try {
await response.locals.authenticator.cleanAuthentication(
request,
response,
);
} catch (e: any) {
response.locals.debug('Error during authentication cleanup: %o', e);
}
});
next();
}
// Unauthenticate after the request.
app.use(unauthenticate);
async function addServerHeader(
_request: Request,
response: AuthResponse,
next: NextFunction,
) {
response.set({
Server: `${pkg.name}/${pkg.version}`,
});
next();
}
// Add the server header to the response.
app.use(addServerHeader);
async function checkRequestPath(
request: Request,
response: AuthResponse,
next: NextFunction,
) {
const splitPath = request.path.split('/');
if (splitPath.includes('..') || splitPath.includes('.')) {
response.status(400);
opts.errorHandler(400, 'Bad request.', request, response);
return;
}
next();
}
// Check the request path for '.' or '..' segments.
app.use(checkRequestPath);
const runMethodCatchErrors = (method: Method) => {
return catchErrors(
method.run.bind(method),
async (code, message, error, [request, response]) => {
await opts.errorHandler(code, message, request, response, error);
},
);
};
// Get the final adapter (after authentication).
app.use(loadAdapter);
// Run plugin begin.
app.use(
async (request: Request, response: AuthResponse, next: NextFunction) => {
let ended = false;
await catchErrors(
async () => {
for (let plugin of response.locals.plugins) {
if ('begin' in plugin && plugin.begin) {
const result = await plugin.begin(request, response);
if (result === false) {
ended = true;
}
}
}
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
ended = true;
},
)();
if (!ended) {
next();
}
},
);
// Run plugin close.
app.use(
async (request: Request, response: AuthResponse, next: NextFunction) => {
response.on('close', async () => {
await catchErrors(
async () => {
for (let plugin of response.locals.plugins) {
if ('close' in plugin && plugin.close) {
await plugin.close(request, response);
}
}
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
},
)();
});
next();
},
);
app.options('/{*splat}', runMethodCatchErrors(new OPTIONS(opts)));
app.get('/{*splat}', runMethodCatchErrors(new GET_HEAD(opts)));
app.head('/{*splat}', runMethodCatchErrors(new GET_HEAD(opts)));
app.put('/{*splat}', runMethodCatchErrors(new PUT(opts)));
app.delete('/{*splat}', runMethodCatchErrors(new DELETE(opts)));
app.copy('/{*splat}', runMethodCatchErrors(new COPY(opts)));
app.move('/{*splat}', runMethodCatchErrors(new MOVE(opts)));
app.mkcol('/{*splat}', runMethodCatchErrors(new MKCOL(opts)));
app.lock('/{*splat}', runMethodCatchErrors(new LOCK(opts)));
app.unlock('/{*splat}', runMethodCatchErrors(new UNLOCK(opts)));
// TODO: Available once rfc5323 is implemented.
// app.search('/{*splat}', runMethodCatchErrors(new SEARCH(opts)));
const propfind = runMethodCatchErrors(new PROPFIND(opts));
const proppatch = runMethodCatchErrors(new PROPPATCH(opts));
app.all('/{*splat}', async (request, response: AuthResponse) => {
switch (request.method) {
case 'PROPFIND':
await propfind(request, response);
break;
case 'PROPPATCH':
await proppatch(request, response);
break;
default:
const run = catchErrors(
async () => {
const pluginMethod = new Method(opts);
let { url } = pluginMethod.getRequestData(request, response);
if (
await pluginMethod.runPlugins(request, response, 'beginMethod', {
method: request.method,
url,
})
) {
return;
}
if (
await pluginMethod.runPlugins(request, response, 'preMethod', {
method: request.method,
url,
})
) {
return;
}
const MethodClass = response.locals.adapter.getMethod(
request.method,
);
const method = new MethodClass(opts);
if (
await method.runPlugins(request, response, 'beforeMethod', {
method,
url,
})
) {
return;
}
await method.run(request, response);
await method.runPlugins(request, response, 'afterMethod', {
method,
url,
});
},
async (code, message, error) => {
await opts.errorHandler(code, message, request, response, error);
},
);
await run();
break;
}
});
debug('Nephele server set up. Ready to start listening.');
return app;
}