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.
Files changed (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -5,113 +5,22 @@ const MillasResponse = require('./MillasResponse');
5
5
  /**
6
6
  * ResponseDispatcher
7
7
  *
8
- * The only place in the entire framework where Express res methods are called.
9
- * Takes a MillasResponse value object and drives the Express response.
8
+ * Kernel-side utility handles auto-wrapping plain return values into
9
+ * MillasResponse objects.
10
10
  *
11
- * This is an internal kernel component developers never call this directly.
12
- * The Router calls it after the handler (and middleware pipeline) resolves.
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
- * Dispatch a MillasResponse to the Express res.
20
+ * Auto-wrap a plain JS return value into a MillasResponse.
18
21
  *
19
- * @param {MillasResponse} response
20
- * @param {import('express').Response} expressRes
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
- // Detect HTML (starts with < tag) vs plain text
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 (typeof value === 'object' || typeof value === 'number' || typeof value === 'boolean') {
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;