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.
- package/README.md +25 -10
- package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +38653 -32291
- package/dist/s3db.es.js.map +1 -1
- package/package.json +218 -22
- package/src/concerns/id.js +90 -6
- package/src/concerns/index.js +2 -1
- package/src/concerns/password-hashing.js +150 -0
- package/src/database.class.js +6 -2
- package/src/plugins/api/auth/basic-auth.js +40 -10
- package/src/plugins/api/auth/index.js +49 -3
- package/src/plugins/api/auth/oauth2-auth.js +171 -0
- package/src/plugins/api/auth/oidc-auth.js +789 -0
- package/src/plugins/api/auth/oidc-client.js +462 -0
- package/src/plugins/api/auth/path-auth-matcher.js +284 -0
- package/src/plugins/api/concerns/event-emitter.js +134 -0
- package/src/plugins/api/concerns/failban-manager.js +651 -0
- package/src/plugins/api/concerns/guards-helpers.js +402 -0
- package/src/plugins/api/concerns/metrics-collector.js +346 -0
- package/src/plugins/api/index.js +510 -57
- package/src/plugins/api/middlewares/failban.js +305 -0
- package/src/plugins/api/middlewares/rate-limit.js +301 -0
- package/src/plugins/api/middlewares/request-id.js +74 -0
- package/src/plugins/api/middlewares/security-headers.js +120 -0
- package/src/plugins/api/middlewares/session-tracking.js +194 -0
- package/src/plugins/api/routes/auth-routes.js +119 -78
- package/src/plugins/api/routes/resource-routes.js +73 -30
- package/src/plugins/api/server.js +1139 -45
- package/src/plugins/api/utils/custom-routes.js +102 -0
- package/src/plugins/api/utils/guards.js +213 -0
- package/src/plugins/api/utils/mime-types.js +154 -0
- package/src/plugins/api/utils/openapi-generator.js +91 -12
- package/src/plugins/api/utils/path-matcher.js +173 -0
- package/src/plugins/api/utils/static-filesystem.js +262 -0
- package/src/plugins/api/utils/static-s3.js +231 -0
- package/src/plugins/api/utils/template-engine.js +188 -0
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
- package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
- package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
- package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
- package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
- package/src/plugins/cloud-inventory/index.js +20 -0
- package/src/plugins/cloud-inventory/registry.js +146 -0
- package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
- package/src/plugins/cloud-inventory.plugin.js +1333 -0
- package/src/plugins/concerns/plugin-dependencies.js +62 -2
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- package/src/plugins/eventual-consistency/consolidation.js +2 -2
- package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
- package/src/plugins/eventual-consistency/install.js +2 -2
- package/src/plugins/identity/README.md +335 -0
- package/src/plugins/identity/concerns/mfa-manager.js +204 -0
- package/src/plugins/identity/concerns/password.js +138 -0
- package/src/plugins/identity/concerns/resource-schemas.js +273 -0
- package/src/plugins/identity/concerns/token-generator.js +172 -0
- package/src/plugins/identity/email-service.js +422 -0
- package/src/plugins/identity/index.js +1052 -0
- package/src/plugins/identity/oauth2-server.js +1033 -0
- package/src/plugins/identity/oidc-discovery.js +285 -0
- package/src/plugins/identity/rsa-keys.js +323 -0
- package/src/plugins/identity/server.js +500 -0
- package/src/plugins/identity/session-manager.js +453 -0
- package/src/plugins/identity/ui/layouts/base.js +251 -0
- package/src/plugins/identity/ui/middleware.js +135 -0
- package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
- package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
- package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
- package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
- package/src/plugins/identity/ui/pages/admin/users.js +263 -0
- package/src/plugins/identity/ui/pages/consent.js +262 -0
- package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
- package/src/plugins/identity/ui/pages/login.js +144 -0
- package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
- package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
- package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
- package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
- package/src/plugins/identity/ui/pages/profile.js +361 -0
- package/src/plugins/identity/ui/pages/register.js +226 -0
- package/src/plugins/identity/ui/pages/reset-password.js +128 -0
- package/src/plugins/identity/ui/pages/verify-email.js +172 -0
- package/src/plugins/identity/ui/routes.js +2541 -0
- package/src/plugins/identity/ui/styles/main.css +465 -0
- package/src/plugins/index.js +4 -1
- package/src/plugins/ml/base-model.class.js +65 -16
- package/src/plugins/ml/classification-model.class.js +1 -1
- package/src/plugins/ml/timeseries-model.class.js +3 -1
- package/src/plugins/ml.plugin.js +584 -31
- package/src/plugins/shared/error-handler.js +147 -0
- package/src/plugins/shared/index.js +9 -0
- package/src/plugins/shared/middlewares/compression.js +117 -0
- package/src/plugins/shared/middlewares/cors.js +49 -0
- package/src/plugins/shared/middlewares/index.js +11 -0
- package/src/plugins/shared/middlewares/logging.js +54 -0
- package/src/plugins/shared/middlewares/rate-limit.js +73 -0
- package/src/plugins/shared/middlewares/security.js +158 -0
- package/src/plugins/shared/response-formatter.js +264 -0
- package/src/plugins/state-machine.plugin.js +57 -2
- package/src/resource.class.js +140 -12
- package/src/schema.class.js +30 -1
- package/src/validator.class.js +57 -6
- 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
|
+
};
|