s3db.js 13.4.0 → 13.6.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.
Files changed (110) hide show
  1. package/README.md +25 -10
  2. package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
  3. package/dist/s3db.cjs.map +1 -0
  4. package/dist/s3db.es.js +38653 -32291
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +218 -22
  7. package/src/concerns/id.js +90 -6
  8. package/src/concerns/index.js +2 -1
  9. package/src/concerns/password-hashing.js +150 -0
  10. package/src/database.class.js +6 -2
  11. package/src/plugins/api/auth/basic-auth.js +40 -10
  12. package/src/plugins/api/auth/index.js +49 -3
  13. package/src/plugins/api/auth/oauth2-auth.js +171 -0
  14. package/src/plugins/api/auth/oidc-auth.js +789 -0
  15. package/src/plugins/api/auth/oidc-client.js +462 -0
  16. package/src/plugins/api/auth/path-auth-matcher.js +284 -0
  17. package/src/plugins/api/concerns/event-emitter.js +134 -0
  18. package/src/plugins/api/concerns/failban-manager.js +651 -0
  19. package/src/plugins/api/concerns/guards-helpers.js +402 -0
  20. package/src/plugins/api/concerns/metrics-collector.js +346 -0
  21. package/src/plugins/api/index.js +510 -57
  22. package/src/plugins/api/middlewares/failban.js +305 -0
  23. package/src/plugins/api/middlewares/rate-limit.js +301 -0
  24. package/src/plugins/api/middlewares/request-id.js +74 -0
  25. package/src/plugins/api/middlewares/security-headers.js +120 -0
  26. package/src/plugins/api/middlewares/session-tracking.js +194 -0
  27. package/src/plugins/api/routes/auth-routes.js +119 -78
  28. package/src/plugins/api/routes/resource-routes.js +73 -30
  29. package/src/plugins/api/server.js +1139 -45
  30. package/src/plugins/api/utils/custom-routes.js +102 -0
  31. package/src/plugins/api/utils/guards.js +213 -0
  32. package/src/plugins/api/utils/mime-types.js +154 -0
  33. package/src/plugins/api/utils/openapi-generator.js +91 -12
  34. package/src/plugins/api/utils/path-matcher.js +173 -0
  35. package/src/plugins/api/utils/static-filesystem.js +262 -0
  36. package/src/plugins/api/utils/static-s3.js +231 -0
  37. package/src/plugins/api/utils/template-engine.js +188 -0
  38. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
  39. package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
  40. package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
  41. package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
  42. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
  43. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
  44. package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
  45. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
  46. package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
  47. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
  48. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
  49. package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
  50. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
  51. package/src/plugins/cloud-inventory/index.js +20 -0
  52. package/src/plugins/cloud-inventory/registry.js +146 -0
  53. package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
  54. package/src/plugins/cloud-inventory.plugin.js +1333 -0
  55. package/src/plugins/concerns/plugin-dependencies.js +62 -2
  56. package/src/plugins/eventual-consistency/analytics.js +1 -0
  57. package/src/plugins/eventual-consistency/consolidation.js +2 -2
  58. package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
  59. package/src/plugins/eventual-consistency/install.js +2 -2
  60. package/src/plugins/identity/README.md +335 -0
  61. package/src/plugins/identity/concerns/mfa-manager.js +204 -0
  62. package/src/plugins/identity/concerns/password.js +138 -0
  63. package/src/plugins/identity/concerns/resource-schemas.js +273 -0
  64. package/src/plugins/identity/concerns/token-generator.js +172 -0
  65. package/src/plugins/identity/email-service.js +422 -0
  66. package/src/plugins/identity/index.js +1052 -0
  67. package/src/plugins/identity/oauth2-server.js +1033 -0
  68. package/src/plugins/identity/oidc-discovery.js +285 -0
  69. package/src/plugins/identity/rsa-keys.js +323 -0
  70. package/src/plugins/identity/server.js +500 -0
  71. package/src/plugins/identity/session-manager.js +453 -0
  72. package/src/plugins/identity/ui/layouts/base.js +251 -0
  73. package/src/plugins/identity/ui/middleware.js +135 -0
  74. package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
  75. package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
  76. package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
  77. package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
  78. package/src/plugins/identity/ui/pages/admin/users.js +263 -0
  79. package/src/plugins/identity/ui/pages/consent.js +262 -0
  80. package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
  81. package/src/plugins/identity/ui/pages/login.js +144 -0
  82. package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
  83. package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
  84. package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
  85. package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
  86. package/src/plugins/identity/ui/pages/profile.js +361 -0
  87. package/src/plugins/identity/ui/pages/register.js +226 -0
  88. package/src/plugins/identity/ui/pages/reset-password.js +128 -0
  89. package/src/plugins/identity/ui/pages/verify-email.js +172 -0
  90. package/src/plugins/identity/ui/routes.js +2541 -0
  91. package/src/plugins/identity/ui/styles/main.css +465 -0
  92. package/src/plugins/index.js +4 -1
  93. package/src/plugins/ml/base-model.class.js +65 -16
  94. package/src/plugins/ml/classification-model.class.js +1 -1
  95. package/src/plugins/ml/timeseries-model.class.js +3 -1
  96. package/src/plugins/ml.plugin.js +584 -31
  97. package/src/plugins/shared/error-handler.js +147 -0
  98. package/src/plugins/shared/index.js +9 -0
  99. package/src/plugins/shared/middlewares/compression.js +117 -0
  100. package/src/plugins/shared/middlewares/cors.js +49 -0
  101. package/src/plugins/shared/middlewares/index.js +11 -0
  102. package/src/plugins/shared/middlewares/logging.js +54 -0
  103. package/src/plugins/shared/middlewares/rate-limit.js +73 -0
  104. package/src/plugins/shared/middlewares/security.js +158 -0
  105. package/src/plugins/shared/response-formatter.js +264 -0
  106. package/src/plugins/state-machine.plugin.js +57 -2
  107. package/src/resource.class.js +140 -12
  108. package/src/schema.class.js +30 -1
  109. package/src/validator.class.js +57 -6
  110. package/dist/s3db.cjs.js.map +0 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Path Matcher - Wildcard-based path matching with specificity sorting
3
+ *
4
+ * Supports:
5
+ * - `*` - Match single path segment (e.g., /api/v1/* → /api/v1/users ✅, /api/v1/users/123 ❌)
6
+ * - `**` - Match multiple segments (e.g., /api/** → /api/v1/users ✅, /api/v1/users/123 ✅)
7
+ *
8
+ * Precedence: Most specific path wins (exact > * > **)
9
+ */
10
+
11
+ /**
12
+ * Convert wildcard pattern to regex
13
+ * @param {string} pattern - Path pattern with wildcards (*, **)
14
+ * @returns {RegExp} Compiled regex
15
+ * @private
16
+ */
17
+ function patternToRegex(pattern) {
18
+ // Escape regex special chars except * and /
19
+ let escaped = pattern
20
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&');
21
+
22
+ // Replace ** with a placeholder first (to avoid conflict with *)
23
+ escaped = escaped.replace(/\*\*/g, '__DOUBLE_STAR__');
24
+
25
+ // Replace * with regex that matches any characters except /
26
+ escaped = escaped.replace(/\*/g, '([^/]+)');
27
+
28
+ // Replace placeholder with regex that matches any characters including /
29
+ escaped = escaped.replace(/__DOUBLE_STAR__/g, '(.*)');
30
+
31
+ // Anchor to start and end
32
+ return new RegExp(`^${escaped}$`);
33
+ }
34
+
35
+ /**
36
+ * Check if a path matches a pattern
37
+ * @param {string} pattern - Path pattern with wildcards
38
+ * @param {string} path - Actual request path
39
+ * @returns {boolean} True if path matches pattern
40
+ * @example
41
+ * matchPath('/api/v1/*', '/api/v1/users') // true
42
+ * matchPath('/api/v1/*', '/api/v1/users/123') // false
43
+ * matchPath('/api/v1/**', '/api/v1/users/123') // true
44
+ */
45
+ export function matchPath(pattern, path) {
46
+ const regex = patternToRegex(pattern);
47
+ return regex.test(path);
48
+ }
49
+
50
+ /**
51
+ * Calculate specificity score for a pattern
52
+ * Higher score = more specific = higher precedence
53
+ *
54
+ * Scoring:
55
+ * - Each exact segment: +1000
56
+ * - Each * wildcard: +100
57
+ * - Each ** wildcard: +10
58
+ *
59
+ * @param {string} pattern - Path pattern
60
+ * @returns {number} Specificity score
61
+ * @private
62
+ * @example
63
+ * calculateSpecificity('/api/v1/admin/users') // 4000 (4 exact segments)
64
+ * calculateSpecificity('/api/v1/admin/*') // 3100 (3 exact + 1 *)
65
+ * calculateSpecificity('/api/v1/**') // 2010 (2 exact + 1 **)
66
+ * calculateSpecificity('/api/**') // 1010 (1 exact + 1 **)
67
+ */
68
+ function calculateSpecificity(pattern) {
69
+ const segments = pattern.split('/').filter(s => s !== '');
70
+
71
+ let score = 0;
72
+
73
+ for (const segment of segments) {
74
+ if (segment === '**') {
75
+ score += 10; // Lowest precedence
76
+ } else if (segment === '*') {
77
+ score += 100; // Medium precedence
78
+ } else {
79
+ score += 1000; // Highest precedence (exact match)
80
+ }
81
+ }
82
+
83
+ return score;
84
+ }
85
+
86
+ /**
87
+ * Find the best matching rule for a given path
88
+ * Returns the most specific rule that matches the path
89
+ *
90
+ * @param {Array<Object>} rules - Array of path auth rules
91
+ * @param {string} rules[].pattern - Path pattern
92
+ * @param {Array<string>} rules[].drivers - Auth drivers
93
+ * @param {boolean} rules[].required - Whether auth is required
94
+ * @param {string} path - Request path
95
+ * @returns {Object|null} Best matching rule or null if no match
96
+ * @example
97
+ * const rules = [
98
+ * { pattern: '/api/**', drivers: ['jwt'], required: true },
99
+ * { pattern: '/api/v1/admin/**', drivers: ['jwt', 'apiKey'], required: true },
100
+ * { pattern: '/health/*', required: false }
101
+ * ];
102
+ *
103
+ * findBestMatch(rules, '/api/v1/admin/users');
104
+ * // Returns { pattern: '/api/v1/admin/**', ... } (most specific)
105
+ *
106
+ * findBestMatch(rules, '/health/liveness');
107
+ * // Returns { pattern: '/health/*', required: false }
108
+ */
109
+ export function findBestMatch(rules, path) {
110
+ if (!rules || rules.length === 0) {
111
+ return null;
112
+ }
113
+
114
+ // Find all matching rules
115
+ const matches = rules
116
+ .map(rule => ({
117
+ rule,
118
+ specificity: calculateSpecificity(rule.pattern)
119
+ }))
120
+ .filter(({ rule }) => matchPath(rule.pattern, path))
121
+ .sort((a, b) => b.specificity - a.specificity); // Descending (highest first)
122
+
123
+ // Return most specific match
124
+ return matches.length > 0 ? matches[0].rule : null;
125
+ }
126
+
127
+ /**
128
+ * Validate pathAuth configuration
129
+ * @param {Array<Object>} pathAuth - Path auth rules
130
+ * @throws {Error} If configuration is invalid
131
+ */
132
+ export function validatePathAuth(pathAuth) {
133
+ if (!Array.isArray(pathAuth)) {
134
+ throw new Error('pathAuth must be an array of rules');
135
+ }
136
+
137
+ for (const [index, rule] of pathAuth.entries()) {
138
+ if (!rule.pattern || typeof rule.pattern !== 'string') {
139
+ throw new Error(`pathAuth[${index}]: pattern is required and must be a string`);
140
+ }
141
+
142
+ if (!rule.pattern.startsWith('/')) {
143
+ throw new Error(`pathAuth[${index}]: pattern must start with / (got: ${rule.pattern})`);
144
+ }
145
+
146
+ if (rule.drivers !== undefined && !Array.isArray(rule.drivers)) {
147
+ throw new Error(`pathAuth[${index}]: drivers must be an array (got: ${typeof rule.drivers})`);
148
+ }
149
+
150
+ if (rule.required !== undefined && typeof rule.required !== 'boolean') {
151
+ throw new Error(`pathAuth[${index}]: required must be a boolean (got: ${typeof rule.required})`);
152
+ }
153
+
154
+ // Validate drivers (if specified)
155
+ const validDrivers = ['jwt', 'apiKey', 'basic', 'oauth2', 'oidc'];
156
+ if (rule.drivers) {
157
+ for (const driver of rule.drivers) {
158
+ if (!validDrivers.includes(driver)) {
159
+ throw new Error(
160
+ `pathAuth[${index}]: invalid driver '${driver}'. ` +
161
+ `Valid drivers: ${validDrivers.join(', ')}`
162
+ );
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ export default {
170
+ matchPath,
171
+ findBestMatch,
172
+ validatePathAuth
173
+ };
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Filesystem Static File Driver
3
+ *
4
+ * Serves static files from local filesystem with:
5
+ * - ETag support (304 Not Modified)
6
+ * - Range requests (partial content)
7
+ * - Directory index files
8
+ * - Security (path traversal prevention)
9
+ * - Cache-Control headers
10
+ */
11
+
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+ import { createReadStream } from 'fs';
15
+ import crypto from 'crypto';
16
+ import { getContentType, isCompressible } from './mime-types.js';
17
+
18
+ /**
19
+ * Create filesystem static file handler
20
+ * @param {Object} config - Configuration
21
+ * @param {string} config.root - Root directory to serve files from
22
+ * @param {Array<string>} [config.index] - Index files (e.g., ['index.html'])
23
+ * @param {string|boolean} [config.fallback] - Fallback file for SPA routing (e.g., 'index.html', true uses index[0], false disables)
24
+ * @param {number} [config.maxAge] - Cache max-age in milliseconds
25
+ * @param {string} [config.dotfiles] - How to handle dotfiles ('ignore', 'allow', 'deny')
26
+ * @param {boolean} [config.etag] - Enable ETag generation
27
+ * @param {boolean} [config.cors] - Enable CORS headers
28
+ * @returns {Function} Hono middleware
29
+ */
30
+ export function createFilesystemHandler(config = {}) {
31
+ const {
32
+ root,
33
+ index = ['index.html'],
34
+ fallback = false,
35
+ maxAge = 0,
36
+ dotfiles = 'ignore',
37
+ etag = true,
38
+ cors = false
39
+ } = config;
40
+
41
+ if (!root) {
42
+ throw new Error('Filesystem static handler requires "root" directory');
43
+ }
44
+
45
+ // Resolve root to absolute path
46
+ const absoluteRoot = path.resolve(root);
47
+
48
+ // Determine fallback file
49
+ let fallbackFile = null;
50
+ if (fallback === true) {
51
+ fallbackFile = index[0]; // Use first index file
52
+ } else if (typeof fallback === 'string') {
53
+ fallbackFile = fallback;
54
+ }
55
+
56
+ return async (c) => {
57
+ try {
58
+ // Get requested path (remove leading slash)
59
+ let requestPath = c.req.path.replace(/^\//, '');
60
+
61
+ // Security: Prevent path traversal
62
+ const safePath = path.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, '');
63
+ const fullPath = path.join(absoluteRoot, safePath);
64
+
65
+ // Ensure path is within root directory
66
+ if (!fullPath.startsWith(absoluteRoot)) {
67
+ return c.json({ success: false, error: { message: 'Forbidden' } }, 403);
68
+ }
69
+
70
+ // Check if path exists
71
+ let stats;
72
+ let useFallback = false;
73
+ try {
74
+ stats = await fs.stat(fullPath);
75
+ } catch (err) {
76
+ if (err.code === 'ENOENT' && fallbackFile) {
77
+ // File not found, try fallback
78
+ useFallback = true;
79
+ } else if (err.code === 'ENOENT') {
80
+ return c.json({ success: false, error: { message: 'Not Found' } }, 404);
81
+ } else {
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ // Use fallback file if needed
87
+ let filePath = fullPath;
88
+ if (useFallback) {
89
+ filePath = path.join(absoluteRoot, fallbackFile);
90
+ try {
91
+ stats = await fs.stat(filePath);
92
+ } catch (err) {
93
+ // Fallback file doesn't exist
94
+ return c.json({ success: false, error: { message: 'Not Found' } }, 404);
95
+ }
96
+ }
97
+
98
+ // Handle directories
99
+ if (!useFallback && stats.isDirectory()) {
100
+ // Try index files
101
+ let indexFound = false;
102
+ for (const indexFile of index) {
103
+ const indexPath = path.join(fullPath, indexFile);
104
+ try {
105
+ const indexStats = await fs.stat(indexPath);
106
+ if (indexStats.isFile()) {
107
+ filePath = indexPath;
108
+ stats = indexStats;
109
+ indexFound = true;
110
+ break;
111
+ }
112
+ } catch (err) {
113
+ // Continue to next index file
114
+ }
115
+ }
116
+
117
+ if (!indexFound) {
118
+ // Directory with no index file, try fallback for SPA routing
119
+ if (fallbackFile) {
120
+ filePath = path.join(absoluteRoot, fallbackFile);
121
+ try {
122
+ stats = await fs.stat(filePath);
123
+ } catch (err) {
124
+ return c.json({ success: false, error: { message: 'Forbidden' } }, 403);
125
+ }
126
+ } else {
127
+ return c.json({ success: false, error: { message: 'Forbidden' } }, 403);
128
+ }
129
+ }
130
+ }
131
+
132
+ // Handle dotfiles
133
+ const filename = path.basename(filePath);
134
+ if (filename.startsWith('.')) {
135
+ if (dotfiles === 'deny') {
136
+ return c.json({ success: false, error: { message: 'Forbidden' } }, 403);
137
+ } else if (dotfiles === 'ignore') {
138
+ return c.json({ success: false, error: { message: 'Not Found' } }, 404);
139
+ }
140
+ // 'allow' - continue
141
+ }
142
+
143
+ // Generate ETag (based on mtime + size)
144
+ const etagValue = etag
145
+ ? `"${crypto.createHash('md5').update(`${stats.mtime.getTime()}-${stats.size}`).digest('hex')}"`
146
+ : null;
147
+
148
+ // Check If-None-Match header (ETag)
149
+ if (etagValue) {
150
+ const ifNoneMatch = c.req.header('If-None-Match');
151
+ if (ifNoneMatch === etagValue) {
152
+ return c.body(null, 304, {
153
+ 'ETag': etagValue,
154
+ 'Cache-Control': maxAge > 0 ? `public, max-age=${Math.floor(maxAge / 1000)}` : 'no-cache'
155
+ });
156
+ }
157
+ }
158
+
159
+ // Get content type
160
+ const contentType = getContentType(filename);
161
+
162
+ // Build headers
163
+ const headers = {
164
+ 'Content-Type': contentType,
165
+ 'Content-Length': stats.size.toString(),
166
+ 'Last-Modified': stats.mtime.toUTCString()
167
+ };
168
+
169
+ if (etagValue) {
170
+ headers['ETag'] = etagValue;
171
+ }
172
+
173
+ if (maxAge > 0) {
174
+ headers['Cache-Control'] = `public, max-age=${Math.floor(maxAge / 1000)}`;
175
+ } else {
176
+ headers['Cache-Control'] = 'no-cache';
177
+ }
178
+
179
+ if (cors) {
180
+ headers['Access-Control-Allow-Origin'] = '*';
181
+ headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
182
+ }
183
+
184
+ // Handle Range requests (partial content)
185
+ const rangeHeader = c.req.header('Range');
186
+ if (rangeHeader) {
187
+ const parts = rangeHeader.replace(/bytes=/, '').split('-');
188
+ const start = parseInt(parts[0], 10);
189
+ const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
190
+
191
+ if (start >= stats.size || end >= stats.size) {
192
+ return c.body(null, 416, {
193
+ 'Content-Range': `bytes */${stats.size}`
194
+ });
195
+ }
196
+
197
+ const chunkSize = (end - start) + 1;
198
+ const stream = createReadStream(filePath, { start, end });
199
+
200
+ headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
201
+ headers['Content-Length'] = chunkSize.toString();
202
+ headers['Accept-Ranges'] = 'bytes';
203
+
204
+ return c.body(stream, 206, headers);
205
+ }
206
+
207
+ // Handle HEAD requests
208
+ if (c.req.method === 'HEAD') {
209
+ return c.body(null, 200, headers);
210
+ }
211
+
212
+ // Stream file
213
+ const stream = createReadStream(filePath);
214
+
215
+ return c.body(stream, 200, headers);
216
+
217
+ } catch (err) {
218
+ console.error('[Static Filesystem] Error:', err);
219
+ return c.json({ success: false, error: { message: 'Internal Server Error' } }, 500);
220
+ }
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Validate filesystem config
226
+ * @param {Object} config - Filesystem config
227
+ * @throws {Error} If config is invalid
228
+ */
229
+ export function validateFilesystemConfig(config) {
230
+ if (!config.root || typeof config.root !== 'string') {
231
+ throw new Error('Filesystem static config requires "root" directory (string)');
232
+ }
233
+
234
+ if (config.index !== undefined && !Array.isArray(config.index)) {
235
+ throw new Error('Filesystem static "index" must be an array');
236
+ }
237
+
238
+ if (config.fallback !== undefined && typeof config.fallback !== 'string' && typeof config.fallback !== 'boolean') {
239
+ throw new Error('Filesystem static "fallback" must be a string (filename) or boolean');
240
+ }
241
+
242
+ if (config.maxAge !== undefined && typeof config.maxAge !== 'number') {
243
+ throw new Error('Filesystem static "maxAge" must be a number');
244
+ }
245
+
246
+ if (config.dotfiles !== undefined && !['ignore', 'allow', 'deny'].includes(config.dotfiles)) {
247
+ throw new Error('Filesystem static "dotfiles" must be "ignore", "allow", or "deny"');
248
+ }
249
+
250
+ if (config.etag !== undefined && typeof config.etag !== 'boolean') {
251
+ throw new Error('Filesystem static "etag" must be a boolean');
252
+ }
253
+
254
+ if (config.cors !== undefined && typeof config.cors !== 'boolean') {
255
+ throw new Error('Filesystem static "cors" must be a boolean');
256
+ }
257
+ }
258
+
259
+ export default {
260
+ createFilesystemHandler,
261
+ validateFilesystemConfig
262
+ };
@@ -0,0 +1,231 @@
1
+ /**
2
+ * S3 Static File Driver
3
+ *
4
+ * Serves static files from S3 bucket with:
5
+ * - Streaming mode (proxy through server)
6
+ * - Presigned URL mode (redirect to S3)
7
+ * - ETag support (304 Not Modified)
8
+ * - Range requests (partial content)
9
+ * - Cache-Control headers
10
+ */
11
+
12
+ import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
13
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
14
+ import { getContentType } from './mime-types.js';
15
+
16
+ /**
17
+ * Create S3 static file handler
18
+ * @param {Object} config - Configuration
19
+ * @param {Object} config.s3Client - AWS S3 Client instance
20
+ * @param {string} config.bucket - S3 bucket name
21
+ * @param {string} [config.prefix] - S3 key prefix (e.g., 'static/')
22
+ * @param {boolean} [config.streaming] - Stream files through server (true) or redirect to presigned URL (false)
23
+ * @param {number} [config.signedUrlExpiry] - Presigned URL expiry in seconds (default: 300)
24
+ * @param {number} [config.maxAge] - Cache max-age in milliseconds
25
+ * @param {string} [config.cacheControl] - Custom Cache-Control header
26
+ * @param {string} [config.contentDisposition] - Content-Disposition header
27
+ * @param {boolean} [config.etag] - Enable ETag support
28
+ * @param {boolean} [config.cors] - Enable CORS headers
29
+ * @returns {Function} Hono middleware
30
+ */
31
+ export function createS3Handler(config = {}) {
32
+ const {
33
+ s3Client,
34
+ bucket,
35
+ prefix = '',
36
+ streaming = true,
37
+ signedUrlExpiry = 300,
38
+ maxAge = 0,
39
+ cacheControl,
40
+ contentDisposition = 'inline',
41
+ etag = true,
42
+ cors = false
43
+ } = config;
44
+
45
+ if (!s3Client) {
46
+ throw new Error('S3 static handler requires "s3Client"');
47
+ }
48
+
49
+ if (!bucket) {
50
+ throw new Error('S3 static handler requires "bucket" name');
51
+ }
52
+
53
+ return async (c) => {
54
+ try {
55
+ // Get requested path (remove leading slash)
56
+ let requestPath = c.req.path.replace(/^\//, '');
57
+
58
+ // Build S3 key
59
+ const key = prefix ? `${prefix}${requestPath}` : requestPath;
60
+
61
+ // Security: Prevent path traversal in key
62
+ if (key.includes('..') || key.includes('//')) {
63
+ return c.json({ success: false, error: { message: 'Forbidden' } }, 403);
64
+ }
65
+
66
+ // Get object metadata (HEAD request)
67
+ let metadata;
68
+ try {
69
+ const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key });
70
+ metadata = await s3Client.send(headCommand);
71
+ } catch (err) {
72
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
73
+ return c.json({ success: false, error: { message: 'Not Found' } }, 404);
74
+ }
75
+ throw err;
76
+ }
77
+
78
+ // Check ETag (If-None-Match)
79
+ if (etag && metadata.ETag) {
80
+ const ifNoneMatch = c.req.header('If-None-Match');
81
+ if (ifNoneMatch === metadata.ETag) {
82
+ const headers = {
83
+ 'ETag': metadata.ETag,
84
+ 'Cache-Control': cacheControl || (maxAge > 0 ? `public, max-age=${Math.floor(maxAge / 1000)}` : 'no-cache')
85
+ };
86
+
87
+ if (cors) {
88
+ headers['Access-Control-Allow-Origin'] = '*';
89
+ headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
90
+ }
91
+
92
+ return c.body(null, 304, headers);
93
+ }
94
+ }
95
+
96
+ // MODE 1: Presigned URL (redirect)
97
+ if (!streaming) {
98
+ const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
99
+ const signedUrl = await getSignedUrl(s3Client, getCommand, { expiresIn: signedUrlExpiry });
100
+
101
+ return c.redirect(signedUrl, 302);
102
+ }
103
+
104
+ // MODE 2: Streaming (proxy through server)
105
+
106
+ // Determine content type
107
+ const contentType = metadata.ContentType || getContentType(key);
108
+
109
+ // Build headers
110
+ const headers = {
111
+ 'Content-Type': contentType,
112
+ 'Content-Length': metadata.ContentLength?.toString() || '0',
113
+ 'Last-Modified': metadata.LastModified?.toUTCString() || new Date().toUTCString()
114
+ };
115
+
116
+ if (metadata.ETag && etag) {
117
+ headers['ETag'] = metadata.ETag;
118
+ }
119
+
120
+ if (cacheControl) {
121
+ headers['Cache-Control'] = cacheControl;
122
+ } else if (maxAge > 0) {
123
+ headers['Cache-Control'] = `public, max-age=${Math.floor(maxAge / 1000)}`;
124
+ } else {
125
+ headers['Cache-Control'] = 'no-cache';
126
+ }
127
+
128
+ if (contentDisposition) {
129
+ const filename = key.split('/').pop();
130
+ headers['Content-Disposition'] = `${contentDisposition}; filename="${filename}"`;
131
+ }
132
+
133
+ if (cors) {
134
+ headers['Access-Control-Allow-Origin'] = '*';
135
+ headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
136
+ }
137
+
138
+ // Handle Range requests
139
+ const rangeHeader = c.req.header('Range');
140
+ let getCommand;
141
+
142
+ if (rangeHeader) {
143
+ // Parse range header
144
+ const parts = rangeHeader.replace(/bytes=/, '').split('-');
145
+ const start = parseInt(parts[0], 10);
146
+ const end = parts[1] ? parseInt(parts[1], 10) : metadata.ContentLength - 1;
147
+
148
+ if (start >= metadata.ContentLength || end >= metadata.ContentLength) {
149
+ return c.body(null, 416, {
150
+ 'Content-Range': `bytes */${metadata.ContentLength}`
151
+ });
152
+ }
153
+
154
+ const range = `bytes=${start}-${end}`;
155
+ getCommand = new GetObjectCommand({ Bucket: bucket, Key: key, Range: range });
156
+
157
+ const chunkSize = (end - start) + 1;
158
+ headers['Content-Range'] = `bytes ${start}-${end}/${metadata.ContentLength}`;
159
+ headers['Content-Length'] = chunkSize.toString();
160
+ headers['Accept-Ranges'] = 'bytes';
161
+
162
+ const response = await s3Client.send(getCommand);
163
+
164
+ return c.body(response.Body, 206, headers);
165
+ }
166
+
167
+ // Handle HEAD requests
168
+ if (c.req.method === 'HEAD') {
169
+ return c.body(null, 200, headers);
170
+ }
171
+
172
+ // Stream full file
173
+ getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
174
+ const response = await s3Client.send(getCommand);
175
+
176
+ return c.body(response.Body, 200, headers);
177
+
178
+ } catch (err) {
179
+ console.error('[Static S3] Error:', err);
180
+ return c.json({ success: false, error: { message: 'Internal Server Error' } }, 500);
181
+ }
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Validate S3 config
187
+ * @param {Object} config - S3 config
188
+ * @throws {Error} If config is invalid
189
+ */
190
+ export function validateS3Config(config) {
191
+ if (!config.bucket || typeof config.bucket !== 'string') {
192
+ throw new Error('S3 static config requires "bucket" name (string)');
193
+ }
194
+
195
+ if (config.prefix !== undefined && typeof config.prefix !== 'string') {
196
+ throw new Error('S3 static "prefix" must be a string');
197
+ }
198
+
199
+ if (config.streaming !== undefined && typeof config.streaming !== 'boolean') {
200
+ throw new Error('S3 static "streaming" must be a boolean');
201
+ }
202
+
203
+ if (config.signedUrlExpiry !== undefined && typeof config.signedUrlExpiry !== 'number') {
204
+ throw new Error('S3 static "signedUrlExpiry" must be a number');
205
+ }
206
+
207
+ if (config.maxAge !== undefined && typeof config.maxAge !== 'number') {
208
+ throw new Error('S3 static "maxAge" must be a number');
209
+ }
210
+
211
+ if (config.cacheControl !== undefined && typeof config.cacheControl !== 'string') {
212
+ throw new Error('S3 static "cacheControl" must be a string');
213
+ }
214
+
215
+ if (config.contentDisposition !== undefined && typeof config.contentDisposition !== 'string') {
216
+ throw new Error('S3 static "contentDisposition" must be a string');
217
+ }
218
+
219
+ if (config.etag !== undefined && typeof config.etag !== 'boolean') {
220
+ throw new Error('S3 static "etag" must be a boolean');
221
+ }
222
+
223
+ if (config.cors !== undefined && typeof config.cors !== 'boolean') {
224
+ throw new Error('S3 static "cors" must be a boolean');
225
+ }
226
+ }
227
+
228
+ export default {
229
+ createS3Handler,
230
+ validateS3Config
231
+ };