@devmoods/express-extras 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache.d.ts +6 -1
- package/dist/cache.js +24 -1
- package/dist/cli.js +2 -2
- package/dist/csrf.d.ts +15 -0
- package/dist/csrf.js +104 -0
- package/dist/email.d.ts +2 -2
- package/dist/email.js +2 -9
- package/dist/faktory.d.ts +11 -5
- package/dist/faktory.js +8 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/manage.js +15 -4
- package/dist/middleware.d.ts +2 -1
- package/dist/middleware.js +7 -3
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +128 -2
- package/dist/setupTests.js +2 -0
- package/dist/validate.js +9 -3
- package/package.json +14 -14
package/dist/cache.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { type RedisClient } from './redis.js';
|
|
2
|
+
export interface CacheOptions {
|
|
3
|
+
key?: string;
|
|
4
|
+
expiresMs?: number | null;
|
|
5
|
+
}
|
|
2
6
|
export interface CacheAdapter {
|
|
3
7
|
get(key: string): Promise<any>;
|
|
4
8
|
set(key: string, value: any, expires?: number | null | undefined): Promise<void>;
|
|
@@ -13,5 +17,6 @@ export declare function createCache(adapter: CacheAdapter): {
|
|
|
13
17
|
set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
|
|
14
18
|
get: (key: string) => Promise<any>;
|
|
15
19
|
clear: (key?: string) => Promise<void>;
|
|
16
|
-
wrap: <T>(key: string,
|
|
20
|
+
wrap: <T>(key: string, options: CacheOptions | number | null, fn: () => Promise<T>) => Promise<T>;
|
|
21
|
+
decorate: <T, Arguments extends any[]>(options: CacheOptions | number | null, fn: (...args: Arguments) => T | Promise<T>) => (...args: Arguments) => Promise<T>;
|
|
17
22
|
};
|
package/dist/cache.js
CHANGED
|
@@ -53,19 +53,42 @@ export function createMemoryCacheAdapter() {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
export function createCache(adapter) {
|
|
56
|
-
async function cached(key,
|
|
56
|
+
async function cached(key, options, fn) {
|
|
57
57
|
const cachedValue = await adapter.get(key);
|
|
58
58
|
if (cachedValue != null) {
|
|
59
59
|
return cachedValue;
|
|
60
60
|
}
|
|
61
|
+
const expiresMs = typeof options === 'number' || options == null
|
|
62
|
+
? options
|
|
63
|
+
: options.expiresMs;
|
|
61
64
|
const result = await fn();
|
|
62
65
|
await adapter.set(key, result, expiresMs);
|
|
63
66
|
return result;
|
|
64
67
|
}
|
|
68
|
+
function cachedFn(options, fn) {
|
|
69
|
+
return async (...args) => {
|
|
70
|
+
const baseKey = typeof options === 'object' && options != null
|
|
71
|
+
? options.key
|
|
72
|
+
: undefined;
|
|
73
|
+
const cacheKey = `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${JSON.stringify(args)}`;
|
|
74
|
+
const cachedValue = await adapter.get(cacheKey);
|
|
75
|
+
if (cachedValue != null) {
|
|
76
|
+
return cachedValue;
|
|
77
|
+
}
|
|
78
|
+
const expiresMs = typeof options === 'number' || options == null
|
|
79
|
+
? options
|
|
80
|
+
: options.expiresMs;
|
|
81
|
+
console.log({ expiresMs });
|
|
82
|
+
const result = await fn(...args);
|
|
83
|
+
await adapter.set(cacheKey, result, expiresMs);
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
65
87
|
return {
|
|
66
88
|
set: adapter.set.bind(adapter),
|
|
67
89
|
get: adapter.get.bind(adapter),
|
|
68
90
|
clear: adapter.clear.bind(adapter),
|
|
69
91
|
wrap: cached,
|
|
92
|
+
decorate: cachedFn,
|
|
70
93
|
};
|
|
71
94
|
}
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ export class CLI {
|
|
|
16
16
|
redLine(message, output = stderr) {
|
|
17
17
|
output.write(`${chalk.red(message)}\n`);
|
|
18
18
|
}
|
|
19
|
-
/*
|
|
19
|
+
/* c8 ignore next 10 */
|
|
20
20
|
readInput(question) {
|
|
21
21
|
const rl = readline.createInterface({ input: stdin, output: stderr });
|
|
22
22
|
return new Promise((resolve) => {
|
|
@@ -31,7 +31,7 @@ export class CLI {
|
|
|
31
31
|
const timer = startTimer();
|
|
32
32
|
try {
|
|
33
33
|
await action(...args);
|
|
34
|
-
this.greenLine(`Command succeeded in ${timer.getDuration().toFixed(4)}s
|
|
34
|
+
this.greenLine(`Command succeeded in ${timer.getDuration().toFixed(4)}s`, stderr);
|
|
35
35
|
}
|
|
36
36
|
catch (error) {
|
|
37
37
|
this.redLine(error.message);
|
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RequestHandler, type Request, type Response } from 'express';
|
|
2
|
+
export interface CsrfMiddlewareOptions {
|
|
3
|
+
excempt?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function csrf({ excempt, }?: CsrfMiddlewareOptions): RequestHandler;
|
|
6
|
+
export declare function setCsrfIdentifier(identifier: string): void;
|
|
7
|
+
export declare function useCsrfIdentifier(): string;
|
|
8
|
+
export declare function setCsrfProtectionEnabled(enabled: boolean): void;
|
|
9
|
+
export declare function useCsrfProtectionEnabled(): boolean | undefined;
|
|
10
|
+
interface TokenOptions {
|
|
11
|
+
overwrite?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare const generateCsrfToken: (req: Request<any, any, any, any>, res: Response, options?: TokenOptions) => string;
|
|
14
|
+
export declare function doubleCsrfProtection(req: Request<any, any, any, any>): () => Promise<void>;
|
|
15
|
+
export {};
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import {} from 'express';
|
|
3
|
+
import { setContext, useContext } from './context.js';
|
|
4
|
+
import { Forbidden } from './errors.js';
|
|
5
|
+
import { readEnv } from './utils.js';
|
|
6
|
+
export function csrf({ excempt = false, } = {}) {
|
|
7
|
+
return (req, res, next) => {
|
|
8
|
+
setCsrfProtectionEnabled(!excempt);
|
|
9
|
+
next();
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const CSRF_IDENTIFIER_CONTEXT_KEY = '@csrfRequestIdentifier';
|
|
13
|
+
export function setCsrfIdentifier(identifier) {
|
|
14
|
+
setContext(CSRF_IDENTIFIER_CONTEXT_KEY, identifier);
|
|
15
|
+
}
|
|
16
|
+
export function useCsrfIdentifier() {
|
|
17
|
+
return useContext(CSRF_IDENTIFIER_CONTEXT_KEY) ?? '';
|
|
18
|
+
}
|
|
19
|
+
const CSRF_REQUIRED = '@csrfProtectionEnabled';
|
|
20
|
+
export function setCsrfProtectionEnabled(enabled) {
|
|
21
|
+
setContext(CSRF_REQUIRED, enabled);
|
|
22
|
+
}
|
|
23
|
+
export function useCsrfProtectionEnabled() {
|
|
24
|
+
return useContext(CSRF_REQUIRED);
|
|
25
|
+
}
|
|
26
|
+
const ignoredMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
27
|
+
const cookieName = '__Host-devmoods.x-csrf-token';
|
|
28
|
+
const generateTokenAndHash = (req, { overwrite = false } = {}) => {
|
|
29
|
+
const secret = readEnv('CSRF_SECRET', '');
|
|
30
|
+
const sessionIdentifier = useCsrfIdentifier();
|
|
31
|
+
const csrfCookie = req.cookies[cookieName];
|
|
32
|
+
if (typeof csrfCookie === 'string' && !overwrite) {
|
|
33
|
+
const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
|
|
34
|
+
if (validateTokenHash({
|
|
35
|
+
csrfToken,
|
|
36
|
+
csrfTokenHash,
|
|
37
|
+
secret,
|
|
38
|
+
sessionIdentifier,
|
|
39
|
+
})) {
|
|
40
|
+
return { csrfToken, csrfTokenHash };
|
|
41
|
+
}
|
|
42
|
+
throw Forbidden('Invalid CSRF token');
|
|
43
|
+
}
|
|
44
|
+
const csrfToken = randomBytes(64).toString('hex');
|
|
45
|
+
const csrfTokenHash = createHash('sha256')
|
|
46
|
+
.update(`${sessionIdentifier}${csrfToken}${secret}`)
|
|
47
|
+
.digest('hex');
|
|
48
|
+
return { csrfToken, csrfTokenHash };
|
|
49
|
+
};
|
|
50
|
+
export const generateCsrfToken = (req, res, options = {}) => {
|
|
51
|
+
const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
|
|
52
|
+
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
|
|
53
|
+
res.cookie(cookieName, cookieContent, {
|
|
54
|
+
sameSite: 'lax',
|
|
55
|
+
path: '/',
|
|
56
|
+
secure: process.env.NODE_ENV === 'production',
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
});
|
|
59
|
+
return csrfToken;
|
|
60
|
+
};
|
|
61
|
+
const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier, }) => {
|
|
62
|
+
if (typeof csrfToken !== 'string' || typeof csrfTokenHash !== 'string') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const expectedHash = createHash('sha256')
|
|
66
|
+
.update(`${sessionIdentifier}${csrfToken}${secret}`)
|
|
67
|
+
.digest('hex');
|
|
68
|
+
return csrfTokenHash === expectedHash;
|
|
69
|
+
};
|
|
70
|
+
const validateRequest = (req) => {
|
|
71
|
+
const csrfCookie = req.cookies[cookieName];
|
|
72
|
+
if (typeof csrfCookie !== 'string') {
|
|
73
|
+
throw Forbidden('Missing CSRF cookie');
|
|
74
|
+
}
|
|
75
|
+
const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
|
|
76
|
+
const csrfTokenFromRequest = req.headers['x-csrf-token'];
|
|
77
|
+
const secret = readEnv('CSRF_SECRET', '');
|
|
78
|
+
if (!csrfTokenFromRequest) {
|
|
79
|
+
throw Forbidden('Missing CSRF token');
|
|
80
|
+
}
|
|
81
|
+
if (csrfToken !== csrfTokenFromRequest) {
|
|
82
|
+
throw Forbidden('CSRF token mismatch');
|
|
83
|
+
}
|
|
84
|
+
if (!validateTokenHash({
|
|
85
|
+
csrfToken: csrfTokenFromRequest,
|
|
86
|
+
csrfTokenHash,
|
|
87
|
+
secret,
|
|
88
|
+
sessionIdentifier: useCsrfIdentifier(),
|
|
89
|
+
})) {
|
|
90
|
+
throw Forbidden('Invalid CSRF token');
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
export function doubleCsrfProtection(req) {
|
|
94
|
+
return async () => {
|
|
95
|
+
const isCsrfProtectionEnabled = useCsrfProtectionEnabled();
|
|
96
|
+
if (!isCsrfProtectionEnabled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (ignoredMethods.has(req.method)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
validateRequest(req);
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/email.d.ts
CHANGED
|
@@ -6,12 +6,12 @@ export interface EmailOptions {
|
|
|
6
6
|
pass?: string;
|
|
7
7
|
senderName: string;
|
|
8
8
|
senderAddress: string;
|
|
9
|
+
onEmailSent?: (payload: Record<string, any>) => void | Promise<void>;
|
|
9
10
|
}
|
|
10
11
|
export interface SendEmailOptions extends nodemailer.SendMailOptions {
|
|
11
12
|
message: string;
|
|
12
13
|
}
|
|
13
|
-
export declare function createEmail({ host, port, user, pass, senderName, senderAddress, }: EmailOptions): {
|
|
14
|
+
export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }: EmailOptions): {
|
|
14
15
|
send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
|
|
15
16
|
transporter: nodemailer.Transporter<import("nodemailer/lib/smtp-transport/index.js").SentMessageInfo, import("nodemailer/lib/smtp-transport/index.js").Options>;
|
|
16
|
-
readonly outbox: Record<string, any>[];
|
|
17
17
|
};
|
package/dist/email.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import * as nodemailer from 'nodemailer';
|
|
2
2
|
import { getLogger } from './logger.js';
|
|
3
3
|
const logger = getLogger('email');
|
|
4
|
-
export function createEmail({ host, port, user, pass, senderName, senderAddress, }) {
|
|
4
|
+
export function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }) {
|
|
5
5
|
const transporter = nodemailer.createTransport({
|
|
6
6
|
host,
|
|
7
7
|
port: parseInt(port || '587', 10),
|
|
8
8
|
secure: false,
|
|
9
9
|
auth: { user, pass },
|
|
10
10
|
});
|
|
11
|
-
const outbox = [];
|
|
12
11
|
const send = async ({ to, subject, message, ...rest }) => {
|
|
13
12
|
const payload = {
|
|
14
13
|
from: `${senderName} <${senderAddress}>`,
|
|
@@ -18,17 +17,11 @@ export function createEmail({ host, port, user, pass, senderName, senderAddress,
|
|
|
18
17
|
...rest,
|
|
19
18
|
};
|
|
20
19
|
const result = await transporter.sendMail(payload);
|
|
21
|
-
|
|
22
|
-
if (outbox.length > 5) {
|
|
23
|
-
outbox.shift();
|
|
24
|
-
}
|
|
20
|
+
await onEmailSent?.(payload);
|
|
25
21
|
logger.info('email-sent', { email: result });
|
|
26
22
|
};
|
|
27
23
|
return {
|
|
28
24
|
send,
|
|
29
25
|
transporter,
|
|
30
|
-
get outbox() {
|
|
31
|
-
return outbox;
|
|
32
|
-
},
|
|
33
26
|
};
|
|
34
27
|
}
|
package/dist/faktory.d.ts
CHANGED
|
@@ -3,14 +3,20 @@ export interface JobsOptions {
|
|
|
3
3
|
queueName: string;
|
|
4
4
|
faktory?: typeof create;
|
|
5
5
|
}
|
|
6
|
-
export type
|
|
7
|
-
export
|
|
8
|
-
|
|
6
|
+
export type AnyJobContext = Record<string, any>;
|
|
7
|
+
export interface JobOptions {
|
|
8
|
+
retry?: number;
|
|
9
|
+
priority?: number;
|
|
10
|
+
at?: Date | string;
|
|
11
|
+
}
|
|
12
|
+
export declare function createJobs<GlobalContext extends AnyJobContext>({ queueName, faktory: createFactory, }: JobsOptions): {
|
|
13
|
+
job: <Arguments extends Record<string, any>, Fn extends (input: Arguments, ctx: GlobalContext) => undefined | Promise<any>>(optionsOrFn: JobOptions | Fn, fnOrUndefined?: Fn) => {
|
|
9
14
|
$job: boolean;
|
|
10
15
|
jobName: string;
|
|
16
|
+
options: JobOptions;
|
|
11
17
|
readonly executionCount: number;
|
|
12
|
-
delay(args?: Parameters<
|
|
13
|
-
execute(args: Parameters<
|
|
18
|
+
delay(args?: Parameters<Fn>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
|
|
19
|
+
execute(args: Parameters<Fn>[0], context: GlobalContext): Promise<any>;
|
|
14
20
|
};
|
|
15
21
|
queueName: string;
|
|
16
22
|
};
|
package/dist/faktory.js
CHANGED
|
@@ -7,7 +7,12 @@ const logger = getLogger('faktory');
|
|
|
7
7
|
const JOB_ID = 'jobId';
|
|
8
8
|
export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
9
9
|
const faktory = createFactory();
|
|
10
|
-
function job(
|
|
10
|
+
function job(optionsOrFn, fnOrUndefined) {
|
|
11
|
+
const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined;
|
|
12
|
+
const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
|
|
13
|
+
if (!fn) {
|
|
14
|
+
throw new Error('job() must get a function as the first or second argument');
|
|
15
|
+
}
|
|
11
16
|
const jobName = fn.name;
|
|
12
17
|
let executionCount = 0;
|
|
13
18
|
if (!jobName) {
|
|
@@ -16,6 +21,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
16
21
|
return {
|
|
17
22
|
$job: true,
|
|
18
23
|
jobName,
|
|
24
|
+
options,
|
|
19
25
|
get executionCount() {
|
|
20
26
|
return executionCount;
|
|
21
27
|
},
|
|
@@ -26,6 +32,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
26
32
|
const j = client.job(jobName, args);
|
|
27
33
|
j.jid = jid;
|
|
28
34
|
j.queue = queueName;
|
|
35
|
+
Object.assign(j, options);
|
|
29
36
|
await j.push();
|
|
30
37
|
await client.close();
|
|
31
38
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* c8 ignore next 100 */
|
|
2
2
|
export * from './auth.js';
|
|
3
3
|
export * from './cache.js';
|
|
4
4
|
export * from './cli.js';
|
|
5
5
|
export * from './context.js';
|
|
6
|
+
export * from './csrf.js';
|
|
6
7
|
export * from './email.js';
|
|
7
8
|
export * from './errors.js';
|
|
8
9
|
export * from './faktory.js';
|
package/dist/manage.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { program } from 'commander';
|
|
2
2
|
import {} from 'express';
|
|
3
|
-
import { parseExpressApp } from 'express-route-parser';
|
|
4
3
|
import { hashPassword } from './auth.js';
|
|
5
4
|
import { CLI } from './cli.js';
|
|
6
5
|
import { asCli } from './migrate.js';
|
|
6
|
+
import { parseRoutes } from './routes.js';
|
|
7
7
|
export function registerManagementCommand(register) {
|
|
8
8
|
const cli = new CLI({ printStackTraces: process.argv.includes('--verbose') });
|
|
9
9
|
register(program.command.bind(program), cli);
|
|
@@ -17,12 +17,23 @@ export function registerDefaultCommands(app) {
|
|
|
17
17
|
const migrate = command('migrate');
|
|
18
18
|
asCli(migrate, cli);
|
|
19
19
|
});
|
|
20
|
-
registerManagementCommand((command, cli) => command('routes')
|
|
20
|
+
registerManagementCommand((command, cli) => command('routes')
|
|
21
|
+
.option('--openapi', 'Output OpenAPI schema')
|
|
22
|
+
.option('--json', 'Output JSON')
|
|
23
|
+
.action(cli.wrap(async (options) => {
|
|
21
24
|
if (!app)
|
|
22
25
|
return;
|
|
23
|
-
|
|
26
|
+
if (options.openapi) {
|
|
27
|
+
console.error('Not implemented yet');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const routes = parseRoutes(app);
|
|
31
|
+
if (options.json) {
|
|
32
|
+
console.log(JSON.stringify(routes, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
24
35
|
for (const route of routes) {
|
|
25
|
-
cli.writeLine(`${route.method.
|
|
36
|
+
cli.writeLine(`${route.method.padEnd(7)} ${route.path}\n`);
|
|
26
37
|
}
|
|
27
38
|
})));
|
|
28
39
|
registerManagementCommand((command, cli) => {
|
package/dist/middleware.d.ts
CHANGED
|
@@ -15,13 +15,14 @@ export interface BeforeMiddlewareOptions {
|
|
|
15
15
|
cookies?: boolean;
|
|
16
16
|
helmet?: boolean;
|
|
17
17
|
json?: boolean;
|
|
18
|
+
csrf?: boolean;
|
|
18
19
|
allowUnsafeInlineScripts?: boolean;
|
|
19
20
|
cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Register middleware that must come before the app routes.
|
|
23
24
|
*/
|
|
24
|
-
export declare function before({ cookies, helmet: useHelmet, json: useJson, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
25
|
+
export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
25
26
|
/**
|
|
26
27
|
* Middleware that sends a 404
|
|
27
28
|
*
|
package/dist/middleware.js
CHANGED
|
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
|
|
|
4
4
|
import {} from 'express';
|
|
5
5
|
import helmet from 'helmet';
|
|
6
6
|
import { withRequestContext } from './context.js';
|
|
7
|
+
import { csrf, doubleCsrfProtection } from './csrf.js';
|
|
7
8
|
import { NotFound } from './errors.js';
|
|
8
9
|
import { getLogger } from './logger.js';
|
|
9
10
|
import { startTimer } from './utils.js';
|
|
@@ -14,9 +15,10 @@ export { asyncMiddleware as route };
|
|
|
14
15
|
/**
|
|
15
16
|
* Register middleware that must come before the app routes.
|
|
16
17
|
*/
|
|
17
|
-
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
18
|
+
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
18
19
|
return compose([
|
|
19
20
|
withRequestContext(),
|
|
21
|
+
useCsrf && csrf(),
|
|
20
22
|
logRequestDuration(),
|
|
21
23
|
useHelmet &&
|
|
22
24
|
helmet({
|
|
@@ -48,7 +50,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
48
50
|
},
|
|
49
51
|
}),
|
|
50
52
|
useJson && jsonParser(),
|
|
51
|
-
cookies && cookieParser(),
|
|
53
|
+
(cookies || useCsrf) && cookieParser(),
|
|
52
54
|
analyticsProxy(),
|
|
53
55
|
].filter(Boolean));
|
|
54
56
|
}
|
|
@@ -111,7 +113,9 @@ export function after() {
|
|
|
111
113
|
*/
|
|
112
114
|
export function asyncMiddleware(fn) {
|
|
113
115
|
const handler = (req, res, next) => {
|
|
114
|
-
return Promise.resolve(
|
|
116
|
+
return Promise.resolve()
|
|
117
|
+
.then(doubleCsrfProtection(req))
|
|
118
|
+
.then(() => fn(req, res, next))
|
|
115
119
|
.then((result) => {
|
|
116
120
|
if (Array.isArray(result) &&
|
|
117
121
|
(result.length === 2 || result.length === 3) &&
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,2 +1,22 @@
|
|
|
1
1
|
import { type Express, type RequestHandler } from 'express';
|
|
2
2
|
export declare function createDocsRouter(app: Express, extras?: Record<string, any>): RequestHandler;
|
|
3
|
+
export declare function parseRoutes(app: Express): {
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
params: {
|
|
7
|
+
type: string;
|
|
8
|
+
properties: {
|
|
9
|
+
[k: string]: {
|
|
10
|
+
type: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
required: string[];
|
|
14
|
+
} | null;
|
|
15
|
+
query: {} | null;
|
|
16
|
+
body: {} | null;
|
|
17
|
+
}[];
|
|
18
|
+
export interface Key {
|
|
19
|
+
name: string;
|
|
20
|
+
optional: boolean;
|
|
21
|
+
offset: number;
|
|
22
|
+
}
|
package/dist/routes.js
CHANGED
|
@@ -1,11 +1,137 @@
|
|
|
1
1
|
import {} from 'express';
|
|
2
|
-
import { parseExpressApp } from 'express-route-parser';
|
|
3
2
|
import { route } from './middleware.js';
|
|
4
3
|
export function createDocsRouter(app, extras = {}) {
|
|
5
4
|
return route(async () => {
|
|
6
5
|
return {
|
|
7
6
|
...extras,
|
|
8
|
-
routes:
|
|
7
|
+
routes: parseRoutes(app),
|
|
9
8
|
};
|
|
10
9
|
});
|
|
11
10
|
}
|
|
11
|
+
export function parseRoutes(app) {
|
|
12
|
+
return new ExpressPathParser(app).getPaths().map((routeMetadata) => {
|
|
13
|
+
const { method, path, pathParams, metadata } = routeMetadata;
|
|
14
|
+
return {
|
|
15
|
+
method: method.toLocaleUpperCase(),
|
|
16
|
+
path,
|
|
17
|
+
params: buildPathParamsSchema(pathParams),
|
|
18
|
+
query: metadata
|
|
19
|
+
?.filter((m) => typeof m === 'object' && m != null && 'querySchema' in m)
|
|
20
|
+
.at(-1)?.querySchema ?? null,
|
|
21
|
+
body: metadata
|
|
22
|
+
?.filter((m) => typeof m === 'object' && m != null && 'bodySchema' in m)
|
|
23
|
+
.at(-1)?.bodySchema ?? null,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const buildPathParamsSchema = (pathParams) => {
|
|
28
|
+
const required = [];
|
|
29
|
+
const params = [];
|
|
30
|
+
for (const param of pathParams) {
|
|
31
|
+
if (param.in !== 'path') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
params.push(param.name);
|
|
35
|
+
if (param.required) {
|
|
36
|
+
required.push(param.name);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (params.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: Object.fromEntries(params.map((p) => [p, { type: 'string' }])),
|
|
45
|
+
required,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
// Fork of express-route-parser
|
|
49
|
+
class ExpressPathParser {
|
|
50
|
+
getPaths() {
|
|
51
|
+
return this._appPaths;
|
|
52
|
+
}
|
|
53
|
+
constructor(app) {
|
|
54
|
+
this.traverse = (path, layer, keys) => {
|
|
55
|
+
keys = [...keys, ...layer.keys];
|
|
56
|
+
if (layer.name === 'router' && layer.handle) {
|
|
57
|
+
for (const l of layer.handle.stack) {
|
|
58
|
+
this.traverse(`${path || ''}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!layer.route || !layer.route.stack.length) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this._appPaths.push(this.parseRouteLayer(layer, keys, path));
|
|
65
|
+
};
|
|
66
|
+
this.parseRouteLayer = (layer, keys, basePath) => {
|
|
67
|
+
const lastRequestHandler = layer.route.stack[layer.route.stack.length - 1];
|
|
68
|
+
const pathParams = keys.map((key) => {
|
|
69
|
+
return { name: key.name, in: 'path', required: !key.optional };
|
|
70
|
+
});
|
|
71
|
+
const filtered = layer.route.stack.filter((element) => element?.handle?.metadata);
|
|
72
|
+
const path = (basePath + layer.route.path).replace(/\/{2,}/gi, '/');
|
|
73
|
+
if (filtered.length === 0) {
|
|
74
|
+
return { path, pathParams, method: lastRequestHandler.method };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
path,
|
|
78
|
+
pathParams,
|
|
79
|
+
method: lastRequestHandler.method,
|
|
80
|
+
metadata: filtered.map((f) => f.handle.metadata),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
this._appPaths = [];
|
|
84
|
+
const router = app._router;
|
|
85
|
+
if (router) {
|
|
86
|
+
for (const layer of router.stack) {
|
|
87
|
+
this.traverse('', layer, []);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const pathRegexParser = (layerRegexPath, keys) => {
|
|
93
|
+
if (typeof layerRegexPath === 'string') {
|
|
94
|
+
return layerRegexPath;
|
|
95
|
+
}
|
|
96
|
+
if (layerRegexPath.fast_slash) {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
if (layerRegexPath.fast_star) {
|
|
100
|
+
return '*';
|
|
101
|
+
}
|
|
102
|
+
let mappedPath = '';
|
|
103
|
+
if (keys && keys.length) {
|
|
104
|
+
mappedPath = mapKeysToPath(layerRegexPath, keys);
|
|
105
|
+
}
|
|
106
|
+
const match = layerRegexPath
|
|
107
|
+
.toString()
|
|
108
|
+
.replace('\\/?', '')
|
|
109
|
+
.replace('(?=\\/|$)', '$')
|
|
110
|
+
.match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//);
|
|
111
|
+
if (match) {
|
|
112
|
+
return match[1].replace(/\\(.)/g, '$1').slice(1);
|
|
113
|
+
}
|
|
114
|
+
if (mappedPath) {
|
|
115
|
+
return mappedPath.slice(1);
|
|
116
|
+
}
|
|
117
|
+
return layerRegexPath.toString();
|
|
118
|
+
};
|
|
119
|
+
const mapKeysToPath = (layerRegexPath, keys) => {
|
|
120
|
+
if (!keys || keys.length === 0) {
|
|
121
|
+
throw Error('must include atleast one key to map');
|
|
122
|
+
}
|
|
123
|
+
let convertedSubPath = layerRegexPath.toString();
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
if (key.optional) {
|
|
126
|
+
convertedSubPath = convertedSubPath.replace('(?:\\/([^\\/]+?))?\\', `/:${key.name}?`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
convertedSubPath = convertedSubPath.replace('(?:([^\\/]+?))', `:${key.name}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return convertedSubPath
|
|
133
|
+
.replace('/?(?=\\/|$)/i', '')
|
|
134
|
+
.replace('/^', '')
|
|
135
|
+
.replace(/\\/gi, '')
|
|
136
|
+
.replace(/\/{2,}/gi, '/');
|
|
137
|
+
};
|
package/dist/setupTests.js
CHANGED
package/dist/validate.js
CHANGED
|
@@ -4,33 +4,39 @@ import { BadRequest } from './errors.js';
|
|
|
4
4
|
const Ajv = _Ajv;
|
|
5
5
|
export function validateBody(schema) {
|
|
6
6
|
const validate = compileValidateSchema(schema);
|
|
7
|
-
|
|
7
|
+
const middleware = (req, res, next) => {
|
|
8
8
|
const result = validate(req.body);
|
|
9
9
|
if (!result.isValid) {
|
|
10
10
|
return next(BadRequest({ body: result.errors }));
|
|
11
11
|
}
|
|
12
12
|
next();
|
|
13
13
|
};
|
|
14
|
+
middleware.metadata = { bodySchema: schema };
|
|
15
|
+
return middleware;
|
|
14
16
|
}
|
|
15
17
|
export function validateQuery(schema) {
|
|
16
18
|
const validate = compileValidateSchema(schema, { coerceTypes: true });
|
|
17
|
-
|
|
19
|
+
const middleware = (req, res, next) => {
|
|
18
20
|
const result = validate(req.query);
|
|
19
21
|
if (!result.isValid) {
|
|
20
22
|
return next(BadRequest({ query: result.errors }));
|
|
21
23
|
}
|
|
22
24
|
next();
|
|
23
25
|
};
|
|
26
|
+
middleware.metadata = { querySchema: schema };
|
|
27
|
+
return middleware;
|
|
24
28
|
}
|
|
25
29
|
export function validateParams(schema) {
|
|
26
30
|
const validate = compileValidateSchema(schema, { coerceTypes: true });
|
|
27
|
-
|
|
31
|
+
const middleware = (req, res, next) => {
|
|
28
32
|
const result = validate(req.params);
|
|
29
33
|
if (!result.isValid) {
|
|
30
34
|
return next(BadRequest({ params: result.errors }));
|
|
31
35
|
}
|
|
32
36
|
next();
|
|
33
37
|
};
|
|
38
|
+
middleware.metadata = { paramsSchema: schema };
|
|
39
|
+
return middleware;
|
|
34
40
|
}
|
|
35
41
|
function createAjv(options) {
|
|
36
42
|
const ajv = new Ajv({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@devmoods/eslint-config": "^1.14.0",
|
|
22
22
|
"@types/koa-compose": "^3.2.8",
|
|
23
|
-
"@types/node": "22.
|
|
23
|
+
"@types/node": "22.8.6",
|
|
24
24
|
"@types/supertest": "^6.0.2",
|
|
25
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
26
|
-
"@typescript-eslint/parser": "^8.
|
|
27
|
-
"@vitest/coverage-v8": "^2.1.
|
|
28
|
-
"
|
|
25
|
+
"@typescript-eslint/eslint-plugin": "^8.12.2",
|
|
26
|
+
"@typescript-eslint/parser": "^8.12.2",
|
|
27
|
+
"@vitest/coverage-v8": "^2.1.4",
|
|
28
|
+
"cookiejar": "^2.1.4",
|
|
29
29
|
"eslint": "^8.57.0",
|
|
30
30
|
"eslint-plugin-import": "^2.31.0",
|
|
31
31
|
"eslint-plugin-prettier": "^5.2.1",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"prettier": "3.3.3",
|
|
34
34
|
"supertest": "7.0.0",
|
|
35
35
|
"ts-node": "10.9.2",
|
|
36
|
-
"tsx": "^4.19.
|
|
36
|
+
"tsx": "^4.19.2",
|
|
37
37
|
"typescript": "5.6.3",
|
|
38
|
-
"vitest": "^2.1.
|
|
38
|
+
"vitest": "^2.1.4",
|
|
39
39
|
"vitest-fail-on-console": "^0.7.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@devmoods/fetch": "^4.0.1",
|
|
43
|
-
"@sentry/node": "^8.
|
|
44
|
-
"@sentry/profiling-node": "^8.
|
|
43
|
+
"@sentry/node": "^8.36.0",
|
|
44
|
+
"@sentry/profiling-node": "^8.36.0",
|
|
45
45
|
"@types/bcrypt": "^5.0.2",
|
|
46
46
|
"@types/body-parser": "^1.19.5",
|
|
47
47
|
"@types/cookie-parser": "^1.4.7",
|
|
@@ -54,19 +54,19 @@
|
|
|
54
54
|
"ajv": "^8.17.1",
|
|
55
55
|
"bcrypt": "^5.1.1",
|
|
56
56
|
"body-parser": "^1.20.3",
|
|
57
|
+
"chalk": "^5.3.0",
|
|
57
58
|
"commander": "^12.1.0",
|
|
58
59
|
"compose-middleware": "^5.0.1",
|
|
59
60
|
"cookie-parser": "^1.4.7",
|
|
60
61
|
"dotenv": "^16.4.5",
|
|
61
62
|
"express": "^4.21.1",
|
|
62
|
-
"express-route-parser": "^1.0.5",
|
|
63
63
|
"helmet": "^8.0.0",
|
|
64
64
|
"jsonwebtoken": "^9.0.2",
|
|
65
|
-
"nodemailer": "^6.9.
|
|
66
|
-
"pg": "^8.13.
|
|
65
|
+
"nodemailer": "^6.9.16",
|
|
66
|
+
"pg": "^8.13.1",
|
|
67
67
|
"redis": "^4.7.0",
|
|
68
68
|
"sql-template-strings": "^2.2.2",
|
|
69
|
-
"uuid": "^
|
|
69
|
+
"uuid": "^11.0.2"
|
|
70
70
|
},
|
|
71
71
|
"prettier": {
|
|
72
72
|
"singleQuote": true
|