s3db.js 13.5.1 → 13.6.1
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 +89 -19
- package/dist/{s3db.cjs.js → s3db.cjs} +29780 -24384
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +24263 -18860
- package/dist/s3db.es.js.map +1 -1
- package/package.json +227 -21
- 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 +4 -0
- package/src/plugins/api/auth/basic-auth.js +23 -1
- 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/concerns/opengraph-helper.js +116 -0
- package/src/plugins/api/concerns/state-machine.js +288 -0
- package/src/plugins/api/index.js +514 -54
- 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 +23 -3
- package/src/plugins/api/routes/resource-routes.js +71 -29
- package/src/plugins/api/server.js +1017 -94
- 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 +44 -11
- 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 +262 -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 +61 -1
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- 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 +32 -7
- 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 +124 -32
- 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/tfstate/README.md +126 -126
- 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,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
|
+
};
|