millas 0.2.12-beta-2 → 0.2.13
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/package.json +3 -2
- package/src/admin/Admin.js +122 -38
- package/src/admin/ViewContext.js +12 -3
- package/src/admin/resources/AdminResource.js +10 -0
- package/src/admin/static/admin.css +95 -14
- package/src/admin/views/layouts/base.njk +23 -34
- package/src/admin/views/pages/detail.njk +16 -5
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/list.njk +127 -2
- package/src/admin/views/partials/form-scripts.njk +7 -3
- package/src/admin/views/partials/form-widget.njk +2 -1
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/commands/createsuperuser.js +17 -4
- package/src/commands/serve.js +2 -4
- package/src/container/AppInitializer.js +39 -15
- package/src/container/Application.js +31 -1
- package/src/core/foundation.js +1 -1
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/Translator.js +10 -2
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +3 -1
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/process/Process.js +333 -0
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +3 -0
- package/src/validation/Validator.js +348 -607
- package/src/admin.zip +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createFacade } = require('./Facade');
|
|
4
|
+
const { HashManager, BcryptDriver } = require('../hashing/Hash');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hash facade — Laravel-style hashing.
|
|
8
|
+
*
|
|
9
|
+
* Resolved from the DI container as 'hash'.
|
|
10
|
+
* Falls back to the exported singleton if used before the container boots
|
|
11
|
+
* (e.g. in migrations or standalone scripts).
|
|
12
|
+
*
|
|
13
|
+
* @class
|
|
14
|
+
*
|
|
15
|
+
* ── Core ─────────────────────────────────────────────────────────────────────
|
|
16
|
+
* @property {function(string, object=): Promise<string>} make
|
|
17
|
+
* Hash a plain-text value.
|
|
18
|
+
* Options: { rounds: 14 } — overrides the configured rounds for this call.
|
|
19
|
+
*
|
|
20
|
+
* @property {function(string, string): Promise<boolean>} check
|
|
21
|
+
* Verify a plain-text value against a stored hash.
|
|
22
|
+
* Returns false (never throws) if either argument is falsy.
|
|
23
|
+
*
|
|
24
|
+
* @property {function(string): boolean} needsRehash
|
|
25
|
+
* Returns true if the hash was made with different rounds than currently
|
|
26
|
+
* configured. Use after login to silently upgrade stored hashes:
|
|
27
|
+
* if (Hash.needsRehash(user.password)) {
|
|
28
|
+
* user.password = await Hash.make(plaintext);
|
|
29
|
+
* await user.save();
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* ── Introspection ─────────────────────────────────────────────────────────────
|
|
33
|
+
* @property {function(string): { alg: string, rounds: number }} info
|
|
34
|
+
* Return metadata about a stored hash.
|
|
35
|
+
* Example: Hash.info(hash) → { alg: 'bcrypt', rounds: 12 }
|
|
36
|
+
*
|
|
37
|
+
* @property {function(string): boolean} isHashed
|
|
38
|
+
* Return true if the value looks like a bcrypt hash.
|
|
39
|
+
* Guards against accidentally double-hashing:
|
|
40
|
+
* if (!Hash.isHashed(value)) value = await Hash.make(value);
|
|
41
|
+
*
|
|
42
|
+
* ── Driver access ─────────────────────────────────────────────────────────────
|
|
43
|
+
* @property {function(string=): BcryptDriver} driver
|
|
44
|
+
* Get a specific driver instance:
|
|
45
|
+
* Hash.driver('bcrypt').make('secret', { rounds: 14 });
|
|
46
|
+
*
|
|
47
|
+
* ── Configuration ─────────────────────────────────────────────────────────────
|
|
48
|
+
* @property {function(number): HashManager} setRounds
|
|
49
|
+
* Change the bcrypt rounds at runtime. Busts the driver cache.
|
|
50
|
+
* Useful in tests: Hash.setRounds(4);
|
|
51
|
+
*
|
|
52
|
+
* @property {function(): number} getRounds
|
|
53
|
+
* Return the currently configured bcrypt rounds.
|
|
54
|
+
*
|
|
55
|
+
* ── Testing ───────────────────────────────────────────────────────────────────
|
|
56
|
+
* @property {function(object): void} swap
|
|
57
|
+
* Swap the underlying instance for a fake:
|
|
58
|
+
* Hash.swap({ make: async () => 'hashed', check: async () => true });
|
|
59
|
+
*
|
|
60
|
+
* @property {function(): void} restore
|
|
61
|
+
* Restore the real implementation after a swap.
|
|
62
|
+
*
|
|
63
|
+
* @see src/hashing/Hash.js
|
|
64
|
+
*/
|
|
65
|
+
class Hash extends createFacade('hash') {}
|
|
66
|
+
|
|
67
|
+
module.exports = Hash
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createFacade } = require('./Facade');
|
|
4
|
+
const ProcessService = require('../process/Process');
|
|
5
|
+
const { ProcessManager, PendingProcess,
|
|
6
|
+
ProcessResult, ProcessPool,
|
|
7
|
+
ProcessFailedException } = require('../process/Process');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Process facade — Laravel-style process runner.
|
|
11
|
+
*
|
|
12
|
+
* Resolved from the DI container as 'process'.
|
|
13
|
+
*
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* QUICK START
|
|
16
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* const { Process } = require('millas/facades/Process');
|
|
19
|
+
*
|
|
20
|
+
* // Run a command and get a ProcessResult
|
|
21
|
+
* const result = await Process.run('node --version');
|
|
22
|
+
* console.log(result.output()); // 'v20.1.0\n'
|
|
23
|
+
* console.log(result.successful); // true
|
|
24
|
+
* console.log(result.exitCode()); // 0
|
|
25
|
+
*
|
|
26
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
* BUILDER — chain options before running
|
|
28
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* const result = await Process
|
|
31
|
+
* .path('/var/www/app') // working directory
|
|
32
|
+
* .timeout(120) // kill after 120 seconds
|
|
33
|
+
* .env({ NODE_ENV: 'ci' }) // merge into parent env
|
|
34
|
+
* .quietly() // suppress passthrough to console
|
|
35
|
+
* .throwOnFailure() // throw if exit code != 0
|
|
36
|
+
* .run('npm ci');
|
|
37
|
+
*
|
|
38
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
* SHELL MODE — pipes, &&, ||, globs
|
|
40
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* const result = await Process.shell().run('cat package.json | grep version');
|
|
43
|
+
* const result = await Process.shell().run('npm run build && npm test');
|
|
44
|
+
*
|
|
45
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
* STDIN INPUT
|
|
47
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* const result = await Process.input('hello world').run('cat');
|
|
50
|
+
* console.log(result.output()); // 'hello world'
|
|
51
|
+
*
|
|
52
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
* STREAMING OUTPUT — line-by-line callbacks
|
|
54
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
*
|
|
56
|
+
* await Process.pipe('npm install', {
|
|
57
|
+
* stdout: line => io.emit('deploy:log', line),
|
|
58
|
+
* stderr: line => io.emit('deploy:err', line),
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
* SYNCHRONOUS
|
|
63
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
*
|
|
65
|
+
* const result = Process.quietly().runSync('git rev-parse HEAD');
|
|
66
|
+
* const sha = result.output().trim();
|
|
67
|
+
*
|
|
68
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
* ERROR HANDLING
|
|
70
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
*
|
|
72
|
+
* // Manual check
|
|
73
|
+
* const result = await Process.quietly().run('npm test');
|
|
74
|
+
* if (result.failed) {
|
|
75
|
+
* console.error(result.errorOutput());
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* // Throw on failure
|
|
79
|
+
* const result = await Process.quietly().run('npm test');
|
|
80
|
+
* result.throw(); // throws ProcessFailedException if failed
|
|
81
|
+
*
|
|
82
|
+
* // Auto-throw
|
|
83
|
+
* try {
|
|
84
|
+
* await Process.throwOnFailure().run('npm test');
|
|
85
|
+
* } catch (err) {
|
|
86
|
+
* console.log(err.result.errorOutput()); // ProcessFailedException
|
|
87
|
+
* }
|
|
88
|
+
*
|
|
89
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
* CONCURRENT POOL
|
|
91
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
*
|
|
93
|
+
* const [lint, test, build] = await Process.pool(pool => [
|
|
94
|
+
* pool.quietly().run('npm run lint'),
|
|
95
|
+
* pool.quietly().run('npm test'),
|
|
96
|
+
* pool.quietly().run('npm run build'),
|
|
97
|
+
* ]);
|
|
98
|
+
*
|
|
99
|
+
* if (lint.failed) console.error('Lint failed:', lint.errorOutput());
|
|
100
|
+
* if (test.failed) console.error('Tests failed:', test.errorOutput());
|
|
101
|
+
* if (build.failed) console.error('Build failed:', build.errorOutput());
|
|
102
|
+
*
|
|
103
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
* PROCESSRESULT API
|
|
105
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
*
|
|
107
|
+
* result.output() // stdout as string
|
|
108
|
+
* result.errorOutput() // stderr as string
|
|
109
|
+
* result.exitCode() // number
|
|
110
|
+
* result.successful // true if exitCode === 0
|
|
111
|
+
* result.failed // true if exitCode !== 0
|
|
112
|
+
* result.throw() // throws ProcessFailedException if failed
|
|
113
|
+
* result.throwIfFailed() // alias
|
|
114
|
+
* String(result) // same as result.output()
|
|
115
|
+
*
|
|
116
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
* ENVIRONMENT
|
|
118
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
*
|
|
120
|
+
* // Merge extra vars into parent env (default)
|
|
121
|
+
* Process.env({ DEBUG: '1', PORT: '4000' }).run('node server.js')
|
|
122
|
+
*
|
|
123
|
+
* // Use ONLY these vars — don't inherit parent env
|
|
124
|
+
* Process.env({ PATH: '/usr/bin' }, false).run('node server.js')
|
|
125
|
+
*
|
|
126
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
* TESTING
|
|
128
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
*
|
|
130
|
+
* Process.swap({
|
|
131
|
+
* run: async () => new ProcessResult({ exitCode: 0, stdout: 'mocked', stderr: '' }),
|
|
132
|
+
* });
|
|
133
|
+
*
|
|
134
|
+
* // ... run test ...
|
|
135
|
+
*
|
|
136
|
+
* Process.restore();
|
|
137
|
+
*
|
|
138
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
*
|
|
140
|
+
* @see src/process/Process.js
|
|
141
|
+
*/
|
|
142
|
+
class Process extends createFacade('process') {}
|
|
143
|
+
|
|
144
|
+
module.exports = { Process, ProcessManager, PendingProcess, ProcessResult, ProcessPool, ProcessFailedException };
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
|
|
5
|
+
// ── BcryptDriver ──────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
class BcryptDriver {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} config
|
|
10
|
+
* @param {number} config.rounds — cost factor (default 12)
|
|
11
|
+
*/
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.rounds = config.rounds ?? 12;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hash a plain-text value.
|
|
18
|
+
*
|
|
19
|
+
* await Hash.make('secret');
|
|
20
|
+
* await Hash.make('secret', { rounds: 14 });
|
|
21
|
+
*
|
|
22
|
+
* @param {string} value
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @returns {Promise<string>}
|
|
25
|
+
*/
|
|
26
|
+
async make(value, options = {}) {
|
|
27
|
+
const rounds = options.rounds ?? this.rounds;
|
|
28
|
+
return bcrypt.hash(String(value), rounds);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verify a plain-text value against a stored hash.
|
|
33
|
+
*
|
|
34
|
+
* const ok = await Hash.check('secret', storedHash);
|
|
35
|
+
*
|
|
36
|
+
* @param {string} value
|
|
37
|
+
* @param {string} hashedValue
|
|
38
|
+
* @returns {Promise<boolean>}
|
|
39
|
+
*/
|
|
40
|
+
async check(value, hashedValue) {
|
|
41
|
+
if (!value || !hashedValue) return false;
|
|
42
|
+
return bcrypt.compare(String(value), String(hashedValue));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Determine if a hash needs to be rehashed.
|
|
47
|
+
* Returns true if the hash was made with fewer rounds than currently configured.
|
|
48
|
+
*
|
|
49
|
+
* if (Hash.needsRehash(user.password)) {
|
|
50
|
+
* user.password = await Hash.make(plaintext);
|
|
51
|
+
* await user.save();
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* @param {string} hashedValue
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
needsRehash(hashedValue) {
|
|
58
|
+
try {
|
|
59
|
+
return bcrypt.getRounds(hashedValue) !== this.rounds;
|
|
60
|
+
} catch {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return info about a hash: algorithm, rounds.
|
|
67
|
+
*
|
|
68
|
+
* Hash.info(hash)
|
|
69
|
+
* // → { alg: 'bcrypt', rounds: 12 }
|
|
70
|
+
*
|
|
71
|
+
* @param {string} hashedValue
|
|
72
|
+
* @returns {{ alg: string, rounds: number }}
|
|
73
|
+
*/
|
|
74
|
+
info(hashedValue) {
|
|
75
|
+
try {
|
|
76
|
+
return { alg: 'bcrypt', rounds: bcrypt.getRounds(hashedValue) };
|
|
77
|
+
} catch {
|
|
78
|
+
return { alg: 'unknown', rounds: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check whether a value is already hashed (not plain-text).
|
|
84
|
+
* Detects bcrypt hash format: $2a$, $2b$, $2y$ prefixes.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} value
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
isHashed(value) {
|
|
90
|
+
return /^\$2[aby]\$\d{2}\$/.test(String(value));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── HashManager ───────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* HashManager
|
|
98
|
+
*
|
|
99
|
+
* Laravel-style hashing service. Manages multiple drivers (currently bcrypt)
|
|
100
|
+
* and delegates all calls to the active driver.
|
|
101
|
+
*
|
|
102
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
103
|
+
*
|
|
104
|
+
* const { Hash } = require('millas/facades/Hash');
|
|
105
|
+
*
|
|
106
|
+
* // Hash a password
|
|
107
|
+
* const hashed = await Hash.make('my-password');
|
|
108
|
+
*
|
|
109
|
+
* // Verify
|
|
110
|
+
* const ok = await Hash.check('my-password', hashed); // true
|
|
111
|
+
*
|
|
112
|
+
* // Rehash check (e.g. rounds changed in config)
|
|
113
|
+
* if (Hash.needsRehash(user.password)) {
|
|
114
|
+
* user.password = await Hash.make(plainPassword);
|
|
115
|
+
* await user.save();
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* // Info
|
|
119
|
+
* Hash.info(hashed); // { alg: 'bcrypt', rounds: 12 }
|
|
120
|
+
*
|
|
121
|
+
* // Guard against double-hashing
|
|
122
|
+
* if (!Hash.isHashed(value)) {
|
|
123
|
+
* value = await Hash.make(value);
|
|
124
|
+
* }
|
|
125
|
+
*
|
|
126
|
+
* // Use a different driver temporarily
|
|
127
|
+
* Hash.driver('bcrypt').make('secret', { rounds: 14 });
|
|
128
|
+
*
|
|
129
|
+
* // Adjust rounds globally (e.g. in tests)
|
|
130
|
+
* Hash.setRounds(4);
|
|
131
|
+
*
|
|
132
|
+
* ── Service provider registration ─────────────────────────────────────────────
|
|
133
|
+
*
|
|
134
|
+
* container.singleton('hash', () => new HashManager({ default: 'bcrypt', bcrypt: { rounds: 12 } }));
|
|
135
|
+
* container.alias('Hash', 'hash');
|
|
136
|
+
*/
|
|
137
|
+
class HashManager {
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} config
|
|
140
|
+
* @param {string} config.default — driver name (default: 'bcrypt')
|
|
141
|
+
* @param {object} config.bcrypt — bcrypt driver config
|
|
142
|
+
* @param {number} config.bcrypt.rounds
|
|
143
|
+
*/
|
|
144
|
+
constructor(config = {}) {
|
|
145
|
+
this._config = config;
|
|
146
|
+
this._default = config.default || 'bcrypt';
|
|
147
|
+
this._drivers = new Map();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Driver resolution ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get a named driver instance (cached).
|
|
154
|
+
*
|
|
155
|
+
* @param {string} [name]
|
|
156
|
+
* @returns {BcryptDriver}
|
|
157
|
+
*/
|
|
158
|
+
driver(name) {
|
|
159
|
+
const driverName = name || this._default;
|
|
160
|
+
if (!this._drivers.has(driverName)) {
|
|
161
|
+
this._drivers.set(driverName, this._createDriver(driverName));
|
|
162
|
+
}
|
|
163
|
+
return this._drivers.get(driverName);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_createDriver(name) {
|
|
167
|
+
if (name === 'bcrypt') {
|
|
168
|
+
return new BcryptDriver(this._config.bcrypt || {});
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`[Hash] Unknown driver: "${name}". Only 'bcrypt' is supported.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Configuration helpers ──────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Change the bcrypt rounds at runtime.
|
|
177
|
+
* Useful for lowering cost in test environments:
|
|
178
|
+
*
|
|
179
|
+
* Hash.setRounds(4); // fast in tests
|
|
180
|
+
*
|
|
181
|
+
* @param {number} rounds
|
|
182
|
+
* @returns {this}
|
|
183
|
+
*/
|
|
184
|
+
setRounds(rounds) {
|
|
185
|
+
this._config.bcrypt = this._config.bcrypt || {};
|
|
186
|
+
this._config.bcrypt.rounds = rounds;
|
|
187
|
+
// Bust the cached driver so next call picks up the new config
|
|
188
|
+
this._drivers.delete('bcrypt');
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Return the currently configured bcrypt rounds.
|
|
194
|
+
*
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
getRounds() {
|
|
198
|
+
return (this._config.bcrypt || {}).rounds ?? 12;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Delegated methods (forward to the default driver) ─────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Hash a value using the default driver.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} value
|
|
207
|
+
* @param {object} [options]
|
|
208
|
+
* @param {number} [options.rounds] — override rounds for this call only
|
|
209
|
+
* @returns {Promise<string>}
|
|
210
|
+
*/
|
|
211
|
+
make(value, options = {}) {
|
|
212
|
+
return this.driver().make(value, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check a plain value against a stored hash.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} value
|
|
219
|
+
* @param {string} hashedValue
|
|
220
|
+
* @returns {Promise<boolean>}
|
|
221
|
+
*/
|
|
222
|
+
check(value, hashedValue) {
|
|
223
|
+
return this.driver().check(value, hashedValue);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Determine whether a hash needs to be rehashed (e.g. rounds have changed).
|
|
228
|
+
*
|
|
229
|
+
* @param {string} hashedValue
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
needsRehash(hashedValue) {
|
|
233
|
+
return this.driver().needsRehash(hashedValue);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Return metadata about a hash.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} hashedValue
|
|
240
|
+
* @returns {{ alg: string, rounds: number }}
|
|
241
|
+
*/
|
|
242
|
+
info(hashedValue) {
|
|
243
|
+
return this.driver().info(hashedValue);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Detect whether a value is already hashed (guards against double-hashing).
|
|
248
|
+
*
|
|
249
|
+
* @param {string} value
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
isHashed(value) {
|
|
253
|
+
return this.driver().isHashed(value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Singleton with default config ─────────────────────────────────────────────
|
|
258
|
+
const defaultHash = new HashManager({ default: 'bcrypt', bcrypt: { rounds: 12 } });
|
|
259
|
+
|
|
260
|
+
module.exports = defaultHash;
|
|
261
|
+
module.exports.HashManager = HashManager;
|
|
262
|
+
module.exports.BcryptDriver = BcryptDriver;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HtmlEscape
|
|
5
|
+
*
|
|
6
|
+
* Escapes user-provided strings for safe inclusion in HTML output,
|
|
7
|
+
* preventing Cross-Site Scripting (XSS) attacks.
|
|
8
|
+
*
|
|
9
|
+
* ── The problem ───────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* const name = req.input('name'); // attacker sends: <script>alert(1)</script>
|
|
12
|
+
* return `<p>Hello ${name}</p>`; // → XSS vulnerability
|
|
13
|
+
*
|
|
14
|
+
* ── The solution ──────────────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* const { e } = require('millas/src/http/HtmlEscape');
|
|
17
|
+
* return `<p>Hello ${e(name)}</p>`; // → <p>Hello <script>...</script></p>
|
|
18
|
+
*
|
|
19
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* const { escapeHtml, e, safeHtml } = require('millas/src/http/HtmlEscape');
|
|
22
|
+
*
|
|
23
|
+
* // Escape a single value
|
|
24
|
+
* escapeHtml('<script>alert(1)</script>')
|
|
25
|
+
* // → '<script>alert(1)</script>'
|
|
26
|
+
*
|
|
27
|
+
* // Short alias for use in template literals
|
|
28
|
+
* `<p>${e(user.name)}</p>`
|
|
29
|
+
*
|
|
30
|
+
* // Build a safe HTML response — all interpolated values are escaped
|
|
31
|
+
* return safeHtml`<h1>Hello ${user.name}</h1><p>${user.bio}</p>`;
|
|
32
|
+
*
|
|
33
|
+
* // Mark a value as already-safe (trusted HTML — use with caution)
|
|
34
|
+
* const trusted = new SafeString('<b>bold</b>');
|
|
35
|
+
* safeHtml`<div>${trusted}</div>` // not double-escaped
|
|
36
|
+
*
|
|
37
|
+
* ── In templates ─────────────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* When using a template engine, enable auto-escaping at the engine level.
|
|
40
|
+
* These utilities are for cases where you build HTML strings in route handlers
|
|
41
|
+
* or helpers without a template engine.
|
|
42
|
+
*
|
|
43
|
+
* ── What is escaped ──────────────────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* & → & (must be first to avoid double-escaping)
|
|
46
|
+
* < → <
|
|
47
|
+
* > → >
|
|
48
|
+
* " → " (safe in attribute values)
|
|
49
|
+
* ' → ' (safe in attribute values)
|
|
50
|
+
* / → / (helps close tags inside attribute values)
|
|
51
|
+
* ` → ` (template literal injection)
|
|
52
|
+
* = → = (attribute injection without quotes)
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
// ── SafeString ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A wrapper that marks a string as already-escaped / trusted HTML.
|
|
59
|
+
* safeHtml`` will not escape SafeString instances.
|
|
60
|
+
*
|
|
61
|
+
* const html = new SafeString('<strong>bold</strong>');
|
|
62
|
+
*/
|
|
63
|
+
class SafeString {
|
|
64
|
+
constructor(value) {
|
|
65
|
+
this.value = String(value);
|
|
66
|
+
}
|
|
67
|
+
toString() {
|
|
68
|
+
return this.value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Core escape function ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const ESCAPE_MAP = {
|
|
75
|
+
'&': '&',
|
|
76
|
+
'<': '<',
|
|
77
|
+
'>': '>',
|
|
78
|
+
'"': '"',
|
|
79
|
+
"'": ''',
|
|
80
|
+
'/': '/',
|
|
81
|
+
'`': '`',
|
|
82
|
+
'=': '=',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const ESCAPE_RE = /[&<>"'`=/]/g;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Escape a value for safe inclusion in HTML.
|
|
89
|
+
* Returns an empty string for null/undefined.
|
|
90
|
+
* Non-string values are converted to string first.
|
|
91
|
+
* SafeString instances are returned as-is (already trusted).
|
|
92
|
+
*
|
|
93
|
+
* @param {*} value
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function escapeHtml(value) {
|
|
97
|
+
if (value instanceof SafeString) return value.value;
|
|
98
|
+
if (value === null || value === undefined) return '';
|
|
99
|
+
return String(value).replace(ESCAPE_RE, c => ESCAPE_MAP[c]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Short alias — designed for template literals:
|
|
104
|
+
* `<p>${e(user.name)}</p>`
|
|
105
|
+
*/
|
|
106
|
+
const e = escapeHtml;
|
|
107
|
+
|
|
108
|
+
// ── Tagged template literal ───────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tagged template literal that auto-escapes all interpolated values.
|
|
112
|
+
* Returns a MillasResponse (html type) so it can be returned directly
|
|
113
|
+
* from a route handler.
|
|
114
|
+
*
|
|
115
|
+
* return safeHtml`<h1>Hello ${user.name}</h1>`;
|
|
116
|
+
* return safeHtml`<ul>${items.map(i => safeHtml`<li>${i.name}</li>`).join('')}</ul>`;
|
|
117
|
+
*
|
|
118
|
+
* To include trusted HTML without escaping, wrap it in SafeString:
|
|
119
|
+
* const icon = new SafeString('<svg>...</svg>');
|
|
120
|
+
* return safeHtml`<div>${icon} ${user.name}</div>`;
|
|
121
|
+
*
|
|
122
|
+
* @param {TemplateStringsArray} strings
|
|
123
|
+
* @param {...*} values
|
|
124
|
+
* @returns {string} — escaped HTML string
|
|
125
|
+
*/
|
|
126
|
+
function safeHtml(strings, ...values) {
|
|
127
|
+
let result = '';
|
|
128
|
+
for (let i = 0; i < strings.length; i++) {
|
|
129
|
+
result += strings[i];
|
|
130
|
+
if (i < values.length) {
|
|
131
|
+
result += escapeHtml(values[i]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── ResponseDispatcher integration ───────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Detect whether a string contains unescaped HTML characters that could
|
|
141
|
+
* indicate unsanitized user input was interpolated directly.
|
|
142
|
+
*
|
|
143
|
+
* Used by ResponseDispatcher.autoWrap() to emit a development warning
|
|
144
|
+
* when a route returns an HTML string containing potentially dangerous chars.
|
|
145
|
+
*
|
|
146
|
+
* This is NOT a security control — it is a development-time hint to help
|
|
147
|
+
* developers discover forgotten escaping. The real protection is using
|
|
148
|
+
* safeHtml`` or a template engine with auto-escaping enabled.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} html
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
function containsUnsafeHtmlPatterns(html) {
|
|
154
|
+
// Look for raw script tags, event handlers, javascript: URIs
|
|
155
|
+
// that are commonly injected — not exhaustive, just a hint
|
|
156
|
+
return /<script[\s>]/i.test(html) ||
|
|
157
|
+
/\son\w+\s*=/i.test(html) ||
|
|
158
|
+
/javascript\s*:/i.test(html) ||
|
|
159
|
+
/data\s*:\s*text\/html/i.test(html);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { escapeHtml, e, safeHtml, SafeString, containsUnsafeHtmlPatterns };
|