millas 0.2.12-beta → 0.2.12-beta-2
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 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -5,113 +5,22 @@ const MillasResponse = require('./MillasResponse');
|
|
|
5
5
|
/**
|
|
6
6
|
* ResponseDispatcher
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Kernel-side utility — handles auto-wrapping plain return values into
|
|
9
|
+
* MillasResponse objects.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Actual dispatch to the HTTP engine (setting headers, writing the body)
|
|
12
|
+
* lives in HttpAdapter.dispatch() — that is the only place HTTP-engine
|
|
13
|
+
* APIs are called.
|
|
14
|
+
*
|
|
15
|
+
* This file has zero imports of Express or any HTTP engine.
|
|
13
16
|
*/
|
|
14
17
|
class ResponseDispatcher {
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
|
-
*
|
|
20
|
+
* Auto-wrap a plain JS return value into a MillasResponse.
|
|
18
21
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
static dispatch(response, expressRes) {
|
|
23
|
-
if (!response || !MillasResponse.isResponse(response)) {
|
|
24
|
-
throw new Error(
|
|
25
|
-
'[ResponseDispatcher] Expected a MillasResponse instance. ' +
|
|
26
|
-
'Got: ' + typeof response
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ── Status ──────────────────────────────────────────────────────────────
|
|
31
|
-
expressRes.status(response.statusCode);
|
|
32
|
-
|
|
33
|
-
// ── CORS passthrough headers ─────────────────────────────────────────────
|
|
34
|
-
// CorsMiddleware stores headers on req._corsHeaders when it calls next()
|
|
35
|
-
// (i.e. non-preflight requests). Apply them here so every response carries
|
|
36
|
-
// the CORS headers regardless of what the route handler returned.
|
|
37
|
-
const corsHeaders = expressRes.req?._corsHeaders;
|
|
38
|
-
if (corsHeaders) {
|
|
39
|
-
for (const [name, value] of Object.entries(corsHeaders)) {
|
|
40
|
-
expressRes.setHeader(name, value);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── Response headers ─────────────────────────────────────────────────────
|
|
45
|
-
for (const [name, value] of Object.entries(response.headers)) {
|
|
46
|
-
expressRes.setHeader(name, value);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Cookies ─────────────────────────────────────────────────────────────
|
|
50
|
-
for (const [name, { value, options }] of Object.entries(response.cookies)) {
|
|
51
|
-
if (options.maxAge === 0 || options.expires?.getTime() === 0) {
|
|
52
|
-
expressRes.clearCookie(name, options);
|
|
53
|
-
} else {
|
|
54
|
-
expressRes.cookie(name, value, options);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Body ─────────────────────────────────────────────────────────────────
|
|
59
|
-
const { type, body } = response;
|
|
60
|
-
|
|
61
|
-
switch (type) {
|
|
62
|
-
|
|
63
|
-
case 'json':
|
|
64
|
-
// Let Express handle JSON serialisation and Content-Type
|
|
65
|
-
return expressRes.json(body);
|
|
66
|
-
|
|
67
|
-
case 'html':
|
|
68
|
-
return expressRes.send(body);
|
|
69
|
-
|
|
70
|
-
case 'text':
|
|
71
|
-
return expressRes.send(body);
|
|
72
|
-
|
|
73
|
-
case 'redirect':
|
|
74
|
-
return expressRes.redirect(response.statusCode, body);
|
|
75
|
-
|
|
76
|
-
case 'empty':
|
|
77
|
-
return expressRes.end();
|
|
78
|
-
|
|
79
|
-
case 'file': {
|
|
80
|
-
const { path: filePath, download, name: fileName } = body;
|
|
81
|
-
if (download) {
|
|
82
|
-
return expressRes.download(filePath, fileName || require('path').basename(filePath));
|
|
83
|
-
}
|
|
84
|
-
return expressRes.sendFile(require('path').resolve(filePath));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
case 'view': {
|
|
88
|
-
// Nunjucks (or whatever template engine is wired) renders the template.
|
|
89
|
-
// expressRes.render() is configured by the framework's view engine setup.
|
|
90
|
-
const { template, data } = body;
|
|
91
|
-
return expressRes.render(template, data);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
case 'stream': {
|
|
95
|
-
// body is a readable stream
|
|
96
|
-
if (body && typeof body.pipe === 'function') {
|
|
97
|
-
body.pipe(expressRes);
|
|
98
|
-
} else {
|
|
99
|
-
expressRes.end();
|
|
100
|
-
}
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
default:
|
|
105
|
-
// Unknown type — try to send as-is
|
|
106
|
-
return expressRes.send(body);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Auto-wrap a plain return value into a MillasResponse.
|
|
112
|
-
*
|
|
113
|
-
* Called when a handler returns something that is NOT a MillasResponse —
|
|
114
|
-
* e.g. a plain object, string, number, or array.
|
|
22
|
+
* Called when a route handler returns something that is NOT already
|
|
23
|
+
* a MillasResponse — e.g. a plain object, string, number, or array.
|
|
115
24
|
*
|
|
116
25
|
* @param {*} value
|
|
117
26
|
* @returns {MillasResponse}
|
|
@@ -119,26 +28,24 @@ class ResponseDispatcher {
|
|
|
119
28
|
static autoWrap(value) {
|
|
120
29
|
if (MillasResponse.isResponse(value)) return value;
|
|
121
30
|
|
|
122
|
-
if (value instanceof Error)
|
|
123
|
-
// Let the caller handle errors — don't wrap them into responses
|
|
124
|
-
throw value;
|
|
125
|
-
}
|
|
31
|
+
if (value instanceof Error) throw value;
|
|
126
32
|
|
|
127
33
|
if (typeof value === 'string') {
|
|
128
|
-
|
|
129
|
-
const isHtml = value.trimStart().startsWith('<');
|
|
130
|
-
return isHtml
|
|
34
|
+
return value.trimStart().startsWith('<')
|
|
131
35
|
? MillasResponse.html(value)
|
|
132
36
|
: MillasResponse.text(value);
|
|
133
37
|
}
|
|
134
38
|
|
|
135
|
-
if (
|
|
39
|
+
if (
|
|
40
|
+
typeof value === 'object' ||
|
|
41
|
+
typeof value === 'number' ||
|
|
42
|
+
typeof value === 'boolean'
|
|
43
|
+
) {
|
|
136
44
|
return MillasResponse.json(value);
|
|
137
45
|
}
|
|
138
46
|
|
|
139
|
-
// Fallback
|
|
140
47
|
return MillasResponse.text(String(value));
|
|
141
48
|
}
|
|
142
49
|
}
|
|
143
50
|
|
|
144
|
-
module.exports = ResponseDispatcher;
|
|
51
|
+
module.exports = ResponseDispatcher;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UrlGenerator
|
|
5
|
+
*
|
|
6
|
+
* Laravel-like URL generation service.
|
|
7
|
+
* Registered in the container as 'url' by the framework after boot.
|
|
8
|
+
*
|
|
9
|
+
* Handles:
|
|
10
|
+
* - Absolute and relative URL generation
|
|
11
|
+
* - Named route URLs with parameter substitution
|
|
12
|
+
* - Asset URLs
|
|
13
|
+
* - Secure (HTTPS) URL forcing
|
|
14
|
+
* - Query string appending
|
|
15
|
+
* - Current / previous URL tracking (set by middleware)
|
|
16
|
+
* - Signed URLs with expiry
|
|
17
|
+
*/
|
|
18
|
+
class UrlGenerator {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.baseUrl — e.g. 'http://localhost:3000'
|
|
22
|
+
* @param {RouteRegistry} options.routeRegistry — registered named routes
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this._baseUrl = (options.baseUrl || '').replace(/\/$/, '');
|
|
26
|
+
this._routeRegistry = options.routeRegistry || null;
|
|
27
|
+
this._forcedScheme = null; // 'https' when forceHttps() is called
|
|
28
|
+
this._assetUrl = null; // separate CDN / asset origin
|
|
29
|
+
this._appKey = options.appKey || process.env.APP_KEY || '';
|
|
30
|
+
|
|
31
|
+
// Set by RequestUrlMiddleware on each request
|
|
32
|
+
this._currentUrl = null;
|
|
33
|
+
this._previousUrl = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Base URL ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The configured base URL of the application.
|
|
40
|
+
* URL.base() → 'https://myapp.com'
|
|
41
|
+
*/
|
|
42
|
+
base() {
|
|
43
|
+
return this._resolveBase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── URL generation ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate an absolute URL for a path.
|
|
50
|
+
*
|
|
51
|
+
* URL.to('/users') → 'https://myapp.com/users'
|
|
52
|
+
* URL.to('/users', { id: 1 })→ 'https://myapp.com/users?id=1'
|
|
53
|
+
* URL.to('https://other.com')→ 'https://other.com' (already absolute)
|
|
54
|
+
*/
|
|
55
|
+
to(path, query = {}) {
|
|
56
|
+
if (this._isAbsolute(path)) return this._appendQuery(path, query);
|
|
57
|
+
const base = this._resolveBase();
|
|
58
|
+
const url = base + '/' + path.replace(/^\//, '');
|
|
59
|
+
return this._appendQuery(url, query);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a secure (HTTPS) URL for a path.
|
|
64
|
+
*
|
|
65
|
+
* URL.secure('/login') → 'https://myapp.com/login'
|
|
66
|
+
*/
|
|
67
|
+
secure(path, query = {}) {
|
|
68
|
+
const url = this.to(path, query);
|
|
69
|
+
return url.replace(/^http:\/\//, 'https://');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a relative URL (path only, no origin).
|
|
74
|
+
*
|
|
75
|
+
* URL.relative('/users') → '/users'
|
|
76
|
+
* URL.relative('/users', { page: 2 }) → '/users?page=2'
|
|
77
|
+
*/
|
|
78
|
+
relative(path, query = {}) {
|
|
79
|
+
const normalized = '/' + path.replace(/^\//, '');
|
|
80
|
+
return this._appendQuery(normalized, query);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Named routes ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate a URL for a named route, substituting parameters.
|
|
87
|
+
*
|
|
88
|
+
* // Route: Route.get('/users/:id/posts/:postId', ...).name('users.posts.show')
|
|
89
|
+
* URL.route('users.posts.show', { id: 1, postId: 42 })
|
|
90
|
+
* → 'https://myapp.com/users/1/posts/42'
|
|
91
|
+
*
|
|
92
|
+
* // Extra params become query string
|
|
93
|
+
* URL.route('users.index', { page: 2, search: 'alice' })
|
|
94
|
+
* → 'https://myapp.com/users?page=2&search=alice'
|
|
95
|
+
*
|
|
96
|
+
* // Relative
|
|
97
|
+
* URL.route('users.show', { id: 5 }, { absolute: false })
|
|
98
|
+
* → '/users/5'
|
|
99
|
+
*/
|
|
100
|
+
route(name, params = {}, options = {}) {
|
|
101
|
+
if (!this._routeRegistry) {
|
|
102
|
+
throw new Error('[URL] Route registry not available. Make sure the app is booted.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entry = this._routeRegistry.findByName(name);
|
|
106
|
+
if (!entry) {
|
|
107
|
+
throw new Error(`[URL] No route named "${name}".`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { path, query } = this._substituteParams(entry.path, params);
|
|
111
|
+
const absolute = options.absolute !== false;
|
|
112
|
+
|
|
113
|
+
if (!absolute) return this._appendQuery(path, query);
|
|
114
|
+
return this._appendQuery(this._resolveBase() + path, query);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Assets ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate an asset URL (uses asset origin if configured, else base URL).
|
|
121
|
+
*
|
|
122
|
+
* URL.asset('css/app.css') → 'https://myapp.com/css/app.css'
|
|
123
|
+
* URL.asset('images/logo.png') → 'https://cdn.myapp.com/images/logo.png'
|
|
124
|
+
*/
|
|
125
|
+
asset(path) {
|
|
126
|
+
const origin = this._assetUrl || this._resolveBase();
|
|
127
|
+
return origin.replace(/\/$/, '') + '/' + path.replace(/^\//, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate a secure asset URL.
|
|
132
|
+
*/
|
|
133
|
+
secureAsset(path) {
|
|
134
|
+
return this.asset(path).replace(/^http:\/\//, 'https://');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Current / previous ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The full URL of the current request.
|
|
141
|
+
* Populated by the request context — null outside of a request.
|
|
142
|
+
*/
|
|
143
|
+
current() {
|
|
144
|
+
return this._currentUrl;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The full URL of the previous request (from Referer header).
|
|
149
|
+
* Returns fallback if unavailable.
|
|
150
|
+
*
|
|
151
|
+
* URL.previous() → 'https://myapp.com/dashboard'
|
|
152
|
+
* URL.previous('/') → '/' if no previous URL
|
|
153
|
+
*/
|
|
154
|
+
previous(fallback = '/') {
|
|
155
|
+
return this._previousUrl || this.to(fallback);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The path portion of the current URL (no origin).
|
|
160
|
+
*/
|
|
161
|
+
currentPath() {
|
|
162
|
+
if (!this._currentUrl) return null;
|
|
163
|
+
try {
|
|
164
|
+
// If it's a full URL, extract pathname; otherwise treat as path directly
|
|
165
|
+
if (this._isAbsolute(this._currentUrl)) {
|
|
166
|
+
return new globalThis.URL(this._currentUrl).pathname;
|
|
167
|
+
}
|
|
168
|
+
return this._currentUrl.split('?')[0];
|
|
169
|
+
} catch {
|
|
170
|
+
return this._currentUrl;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Signed URLs ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate a signed URL that cannot be tampered with.
|
|
178
|
+
* Optionally expires after a given number of seconds.
|
|
179
|
+
*
|
|
180
|
+
* URL.signedRoute('password.reset', { token }, 3600)
|
|
181
|
+
* → 'https://myapp.com/password/reset?token=...&expires=...&signature=...'
|
|
182
|
+
*/
|
|
183
|
+
signedRoute(name, params = {}, expiresIn = null) {
|
|
184
|
+
const base = this.route(name, params);
|
|
185
|
+
return this._sign(base, expiresIn);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate a signed URL for an arbitrary path.
|
|
190
|
+
*
|
|
191
|
+
* URL.signedUrl('/download/file.pdf', 300)
|
|
192
|
+
*/
|
|
193
|
+
signedUrl(path, expiresIn = null) {
|
|
194
|
+
const url = this.to(path);
|
|
195
|
+
return this._sign(url, expiresIn);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Verify that a signed URL is valid and has not expired.
|
|
200
|
+
* Returns true if valid, false otherwise.
|
|
201
|
+
*
|
|
202
|
+
* URL.hasValidSignature(req)
|
|
203
|
+
*/
|
|
204
|
+
hasValidSignature(req) {
|
|
205
|
+
const rawUrl = this.to(req.path || req.url || '', req.query || {});
|
|
206
|
+
return this._verifySignature(rawUrl);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Scheme control ────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Force all generated URLs to use HTTPS.
|
|
213
|
+
* URL.forceHttps()
|
|
214
|
+
*/
|
|
215
|
+
forceHttps(force = true) {
|
|
216
|
+
this._forcedScheme = force ? 'https' : null;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Force all generated URLs to use a specific scheme.
|
|
222
|
+
* URL.forceScheme('https')
|
|
223
|
+
*/
|
|
224
|
+
forceScheme(scheme) {
|
|
225
|
+
this._forcedScheme = scheme || null;
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Set a separate origin for asset URLs (CDN, S3, etc.).
|
|
231
|
+
* URL.useAssetOrigin('https://cdn.myapp.com')
|
|
232
|
+
*/
|
|
233
|
+
useAssetOrigin(origin) {
|
|
234
|
+
this._assetUrl = origin ? origin.replace(/\/$/, '') : null;
|
|
235
|
+
return this;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Introspection ──────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check whether a string is a valid absolute URL.
|
|
242
|
+
* URL.isValid('https://example.com') → true
|
|
243
|
+
* URL.isValid('/relative/path') → false
|
|
244
|
+
*/
|
|
245
|
+
isValid(url) {
|
|
246
|
+
try { new URL(url); return true; } catch { return false; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check whether the current request URL matches a pattern.
|
|
251
|
+
* Supports * wildcards.
|
|
252
|
+
*
|
|
253
|
+
* URL.is('/users/*') → true on /users/1, /users/edit
|
|
254
|
+
* URL.is('/users') → true only on /users
|
|
255
|
+
*/
|
|
256
|
+
is(...patterns) {
|
|
257
|
+
const path = this.currentPath() || '';
|
|
258
|
+
return patterns.some(pattern => {
|
|
259
|
+
const regex = new RegExp(
|
|
260
|
+
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
|
261
|
+
);
|
|
262
|
+
return regex.test(path);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Internal: called by framework ─────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** Set by AppInitialiser / request middleware. @internal */
|
|
269
|
+
_setCurrentUrl(url) { this._currentUrl = url; }
|
|
270
|
+
_setPreviousUrl(url) { this._previousUrl = url; }
|
|
271
|
+
|
|
272
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
_resolveBase() {
|
|
275
|
+
let base = process.env.APP_URL || this._baseUrl;
|
|
276
|
+
|
|
277
|
+
if (!base) {
|
|
278
|
+
const host = process.env.MILLAS_HOST || 'localhost';
|
|
279
|
+
const port = process.env.APP_PORT || process.env.MILLAS_PORT || '3000';
|
|
280
|
+
const scheme = this._forcedScheme || 'http';
|
|
281
|
+
base = `${scheme}://${host}:${port}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (this._forcedScheme) {
|
|
285
|
+
base = base.replace(/^https?:\/\//, `${this._forcedScheme}://`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return base.replace(/\/$/, '');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_isAbsolute(url) {
|
|
292
|
+
return /^https?:\/\//.test(url);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_appendQuery(url, query = {}) {
|
|
296
|
+
const pairs = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
|
|
297
|
+
if (!pairs.length) return url;
|
|
298
|
+
const qs = new URLSearchParams(pairs).toString();
|
|
299
|
+
return url + (url.includes('?') ? '&' : '?') + qs;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Replace :param and {param} placeholders in a route path.
|
|
304
|
+
* Returns { path, query } where query holds leftover params.
|
|
305
|
+
*/
|
|
306
|
+
_substituteParams(routePath, params) {
|
|
307
|
+
const remaining = { ...params };
|
|
308
|
+
let path = routePath;
|
|
309
|
+
|
|
310
|
+
// Replace :param and {param} style placeholders
|
|
311
|
+
path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)\??|\{([a-zA-Z_][a-zA-Z0-9_]*)\??}/g, (_, p1, p2) => {
|
|
312
|
+
const key = p1 || p2;
|
|
313
|
+
if (key in remaining) {
|
|
314
|
+
const val = remaining[key];
|
|
315
|
+
delete remaining[key];
|
|
316
|
+
return encodeURIComponent(val);
|
|
317
|
+
}
|
|
318
|
+
return '';
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Remove any trailing slash left by optional segments
|
|
322
|
+
path = path.replace(/\/+$/, '') || '/';
|
|
323
|
+
|
|
324
|
+
return { path, query: remaining };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_sign(url, expiresIn) {
|
|
328
|
+
const crypto = require('crypto');
|
|
329
|
+
let target = url;
|
|
330
|
+
|
|
331
|
+
if (expiresIn) {
|
|
332
|
+
const expires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
333
|
+
target = this._appendQuery(url, { expires });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const signature = crypto
|
|
337
|
+
.createHmac('sha256', this._appKey)
|
|
338
|
+
.update(target)
|
|
339
|
+
.digest('hex')
|
|
340
|
+
.slice(0, 40);
|
|
341
|
+
|
|
342
|
+
return this._appendQuery(target, { signature });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_verifySignature(url) {
|
|
346
|
+
const crypto = require('crypto');
|
|
347
|
+
try {
|
|
348
|
+
const parsed = new URL(url);
|
|
349
|
+
const signature = parsed.searchParams.get('signature');
|
|
350
|
+
const expires = parsed.searchParams.get('expires');
|
|
351
|
+
|
|
352
|
+
if (!signature) return false;
|
|
353
|
+
|
|
354
|
+
if (expires && Math.floor(Date.now() / 1000) > Number(expires)) {
|
|
355
|
+
return false; // expired
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Reconstruct the URL without the signature param to re-sign
|
|
359
|
+
parsed.searchParams.delete('signature');
|
|
360
|
+
const unsigned = parsed.toString();
|
|
361
|
+
|
|
362
|
+
const expected = require('crypto')
|
|
363
|
+
.createHmac('sha256', this._appKey)
|
|
364
|
+
.update(unsigned)
|
|
365
|
+
.digest('hex')
|
|
366
|
+
.slice(0, 40);
|
|
367
|
+
|
|
368
|
+
return signature === expected;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = UrlGenerator;
|